Commit aa000c1a by Nimisha Asthagiri

Support for rescoring a problem only if the new score is higher

TNL-5046
parent 0e3e1b47
......@@ -1048,7 +1048,7 @@ class CapaMixin(CapaFields):
return answers
def publish_grade(self):
def publish_grade(self, only_if_higher=None):
"""
Publishes the student's current grade to the system as an event
"""
......@@ -1059,6 +1059,7 @@ class CapaMixin(CapaFields):
{
'value': score['score'],
'max_value': score['total'],
'only_if_higher': only_if_higher,
}
)
......@@ -1370,13 +1371,16 @@ class CapaMixin(CapaFields):
return input_metadata
def rescore_problem(self):
def rescore_problem(self, only_if_higher):
"""
Checks whether the existing answers to a problem are correct.
This is called when the correct answer to a problem has been changed,
and the grade should be re-evaluated.
If only_if_higher is True, the answer and grade are updated
only if the resulting score is higher than before.
Returns a dict with one key:
{'success' : 'correct' | 'incorrect' | AJAX alert msg string }
......@@ -1428,7 +1432,7 @@ class CapaMixin(CapaFields):
# need to increment here, or mark done. Just save.
self.set_state_from_lcp()
self.publish_grade()
self.publish_grade(only_if_higher)
new_score = self.lcp.get_score()
event_info['new_score'] = new_score['score']
......
......@@ -487,15 +487,14 @@ class CapaModuleTest(unittest.TestCase):
# Simulate that all answers are marked correct, no matter
# what the input is, by patching CorrectMap.is_correct()
# Also simulate rendering the HTML
# TODO: pep8 thinks the following line has invalid syntax
with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct, \
patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html:
mock_is_correct.return_value = True
mock_html.return_value = "Test HTML"
with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct:
with patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html:
mock_is_correct.return_value = True
mock_html.return_value = "Test HTML"
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.submit_problem(get_request_dict)
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.submit_problem(get_request_dict)
# Expect that the problem is marked correct
self.assertEqual(result['success'], 'correct')
......@@ -861,7 +860,7 @@ class CapaModuleTest(unittest.TestCase):
# what the input is, by patching LoncapaResponse.evaluate_answers()
with patch('capa.responsetypes.LoncapaResponse.evaluate_answers') as mock_evaluate_answers:
mock_evaluate_answers.return_value = CorrectMap(CapaFactory.answer_key(), 'correct')
result = module.rescore_problem()
result = module.rescore_problem(only_if_higher=False)
# Expect that the problem is marked correct
self.assertEqual(result['success'], 'correct')
......@@ -881,7 +880,7 @@ class CapaModuleTest(unittest.TestCase):
# what the input is, by patching LoncapaResponse.evaluate_answers()
with patch('capa.responsetypes.LoncapaResponse.evaluate_answers') as mock_evaluate_answers:
mock_evaluate_answers.return_value = CorrectMap(CapaFactory.answer_key(), 'incorrect')
result = module.rescore_problem()
result = module.rescore_problem(only_if_higher=False)
# Expect that the problem is marked incorrect
self.assertEqual(result['success'], 'incorrect')
......@@ -895,7 +894,7 @@ class CapaModuleTest(unittest.TestCase):
# Try to rescore the problem, and get exception
with self.assertRaises(xmodule.exceptions.NotFoundError):
module.rescore_problem()
module.rescore_problem(only_if_higher=False)
def test_rescore_problem_not_supported(self):
module = CapaFactory.create(done=True)
......@@ -904,7 +903,7 @@ class CapaModuleTest(unittest.TestCase):
with patch('capa.capa_problem.LoncapaProblem.supports_rescoring') as mock_supports_rescoring:
mock_supports_rescoring.return_value = False
with self.assertRaises(NotImplementedError):
module.rescore_problem()
module.rescore_problem(only_if_higher=False)
def _rescore_problem_error_helper(self, exception_class):
"""Helper to allow testing all errors that rescoring might return."""
......@@ -914,7 +913,7 @@ class CapaModuleTest(unittest.TestCase):
# Simulate answering a problem that raises the exception
with patch('capa.capa_problem.LoncapaProblem.rescore_existing_answers') as mock_rescore:
mock_rescore.side_effect = exception_class(u'test error \u03a9')
result = module.rescore_problem()
result = module.rescore_problem(only_if_higher=False)
# Expect an AJAX alert message in 'success'
expected_msg = u'Error: test error \u03a9'
......@@ -1656,7 +1655,7 @@ class CapaModuleTest(unittest.TestCase):
module.submit_problem(get_request_dict)
# On rescore, state/student_answers should use unmasked names
with patch.object(module.runtime, 'track_function') as mock_track_function:
module.rescore_problem()
module.rescore_problem(only_if_higher=False)
mock_call = mock_track_function.mock_calls[0]
event_info = mock_call[1][1]
self.assertEquals(mock_call[1][0], 'problem_rescore')
......
......@@ -48,12 +48,14 @@ class InstructorDashboardPage(CoursePage):
data_download_section.wait_for_page()
return data_download_section
def select_student_admin(self):
def select_student_admin(self, admin_class):
"""
Selects the student admin tab and returns the MembershipSection
Selects the student admin tab and returns the requested
admin section.
admin_class should be a subclass of StudentAdminPage.
"""
self.q(css='[data-section="student_admin"]').first.click()
student_admin_section = StudentAdminPage(self.browser)
student_admin_section = admin_class(self.browser)
student_admin_section.wait_for_page()
return student_admin_section
......@@ -1025,8 +1027,18 @@ class StudentAdminPage(PageObject):
Student admin section of the Instructor dashboard.
"""
url = None
EE_CONTAINER = ".entrance-exam-grade-container"
CS_CONTAINER = ".course-specific-container"
CONTAINER = None
PROBLEM_INPUT_NAME = None
STUDENT_EMAIL_INPUT_NAME = None
RESET_ATTEMPTS_BUTTON_NAME = None
RESCORE_BUTTON_NAME = None
RESCORE_IF_HIGHER_BUTTON_NAME = None
DELETE_STATE_BUTTON_NAME = None
BACKGROUND_TASKS_BUTTON_NAME = None
TASK_HISTORY_TABLE_NAME = None
def is_browser_on_page(self):
"""
......@@ -1034,167 +1046,185 @@ class StudentAdminPage(PageObject):
"""
return self.q(css='[data-section=student_admin].active-section').present
@property
def student_email_input(self):
def _input_with_name(self, input_name):
"""
Returns email address/username input box.
Returns the input box with the given name
for this object's container.
"""
return self.q(css='{} input[name=entrance-exam-student-select-grade]'.format(self.EE_CONTAINER))
return self.q(css='{} input[name={}]'.format(self.CONTAINER, input_name))
@property
def rescore_problem_input(self):
def problem_location_input(self):
"""
Returns input box for rescore/reset all on a problem
Returns input box for problem location
"""
return self.q(css='{} input[name=problem-select-all]'.format(self.CS_CONTAINER))
return self._input_with_name(self.PROBLEM_INPUT_NAME)
@property
def reset_attempts_button(self):
def set_problem_location(self, problem_location):
"""
Returns reset student attempts button.
Returns input box for problem location
"""
return self.q(css='{} input[name=reset-entrance-exam-attempts]'.format(self.EE_CONTAINER))
input_box = self.problem_location_input.first.results[0]
input_box.send_keys(unicode(problem_location))
@property
def rescore_submission_button(self):
def student_email_or_username_input(self):
"""
Returns rescore student submission button.
Returns email address/username input box.
"""
return self.q(css='{} input[name=rescore-entrance-exam]'.format(self.EE_CONTAINER))
return self._input_with_name(self.STUDENT_EMAIL_INPUT_NAME)
@property
def rescore_all_submissions_button(self):
def set_student_email_or_username(self, email_or_username):
"""
Returns rescore student submission button.
Sets given email or username as value of
student email/username input box.
"""
return self.q(css='{} input[name=rescore-problem-all]'.format(self.CS_CONTAINER))
input_box = self.student_email_or_username_input.first.results[0]
input_box.send_keys(email_or_username)
@property
def show_background_tasks_button(self):
def reset_attempts_button(self):
"""
Return Show Background Tasks button.
Returns reset student attempts button.
"""
return self.q(css='{} input[name=task-history-all]'.format(self.CS_CONTAINER))
return self._input_with_name(self.RESET_ATTEMPTS_BUTTON_NAME)
@property
def skip_entrance_exam_button(self):
def rescore_button(self):
"""
Return Let Student Skip Entrance Exam button.
Returns rescore button.
"""
return self.q(css='{} input[name=skip-entrance-exam]'.format(self.EE_CONTAINER))
return self._input_with_name(self.RESCORE_BUTTON_NAME)
@property
def delete_student_state_button(self):
def rescore_if_higher_button(self):
"""
Returns delete student state button.
Returns rescore if higher button.
"""
return self.q(css='{} input[name=delete-entrance-exam-state]'.format(self.EE_CONTAINER))
return self._input_with_name(self.RESCORE_IF_HIGHER_BUTTON_NAME)
@property
def background_task_history_button(self):
def delete_state_button(self):
"""
Returns show background task history for student button.
Returns delete state button.
"""
return self.q(css='{} input[name=entrance-exam-task-history]'.format(self.EE_CONTAINER))
return self._input_with_name(self.DELETE_STATE_BUTTON_NAME)
@property
def top_notification(self):
def task_history_button(self):
"""
Returns show background task history for student button.
Return Background Tasks History button.
"""
return self.q(css='{} .request-response-error'.format(self.EE_CONTAINER)).first
return self._input_with_name(self.BACKGROUND_TASKS_BUTTON_NAME)
def is_student_email_input_visible(self):
def wait_for_task_history_table(self):
"""
Returns True if student email address/username input box is present.
Waits until the task history table is visible.
"""
return self.student_email_input.is_present()
def check_func():
"""
Promise Check Function
"""
query = self.q(css="{} .{}".format(self.CONTAINER, self.TASK_HISTORY_TABLE_NAME))
return query.visible, query
def is_reset_attempts_button_visible(self):
"""
Returns True if reset student attempts button is present.
"""
return self.reset_attempts_button.is_present()
return Promise(check_func, "Waiting for student admin task history table to be visible.").fulfill()
def is_rescore_submission_button_visible(self):
def wait_for_task_completion(self, expected_task_string):
"""
Returns True if rescore student submission button is present.
Waits until the task history table is visible.
"""
return self.rescore_submission_button.is_present()
def check_func():
"""
Promise Check Function
"""
self.task_history_button.click()
table = self.wait_for_task_history_table()
return len(table) > 0 and expected_task_string in table.results[0].text
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()
return EmptyPromise(check_func, "Waiting for student admin task to complete.").fulfill()
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()
class StudentSpecificAdmin(StudentAdminPage):
"""
Student specific section of the Student Admin page.
"""
CONTAINER = ".student-grade-container"
def click_reset_attempts_button(self):
"""
clicks reset student attempts button.
"""
return self.reset_attempts_button.click()
PROBLEM_INPUT_NAME = "problem-select-single"
STUDENT_EMAIL_INPUT_NAME = "student-select-grade"
def click_rescore_submissions_button(self):
"""
clicks rescore submissions button.
"""
return self.rescore_submission_button.click()
RESET_ATTEMPTS_BUTTON_NAME = "reset-attempts-single"
RESCORE_BUTTON_NAME = "rescore-problem-single"
RESCORE_IF_HIGHER_BUTTON_NAME = "rescore-problem-if-higher-single"
DELETE_STATE_BUTTON_NAME = "delete-state-single"
def click_rescore_all_button(self):
"""
clicks rescore all for problem button.
"""
return self.rescore_all_submissions_button.click()
BACKGROUND_TASKS_BUTTON_NAME = "task-history-single"
TASK_HISTORY_TABLE_NAME = "task-history-single-table"
def click_show_background_tasks_button(self):
"""
clicks show background tasks button.
"""
return self.show_background_tasks_button.click()
def click_skip_entrance_exam_button(self):
"""
clicks let student skip entrance exam button.
"""
return self.skip_entrance_exam_button.click()
class CourseSpecificAdmin(StudentAdminPage):
"""
Course specific section of the Student Admin page.
"""
CONTAINER = ".course-specific-container"
def click_delete_student_state_button(self):
"""
clicks delete student state button.
"""
return self.delete_student_state_button.click()
PROBLEM_INPUT_NAME = "problem-select-all"
STUDENT_EMAIL_INPUT_NAME = None
RESET_ATTEMPTS_BUTTON_NAME = "reset-attempts-all"
RESCORE_BUTTON_NAME = "rescore-problem-all"
RESCORE_IF_HIGHER_BUTTON_NAME = "rescore-problem-all-if-higher"
DELETE_STATE_BUTTON_NAME = None
BACKGROUND_TASKS_BUTTON_NAME = "task-history-all"
TASK_HISTORY_TABLE_NAME = "task-history-all-table"
def click_task_history_button(self):
class EntranceExamAdmin(StudentAdminPage):
"""
Entrance exam section of the Student Admin page.
"""
CONTAINER = ".entrance-exam-grade-container"
STUDENT_EMAIL_INPUT_NAME = "entrance-exam-student-select-grade"
PROBLEM_INPUT_NAME = None
RESET_ATTEMPTS_BUTTON_NAME = "reset-entrance-exam-attempts"
RESCORE_BUTTON_NAME = "rescore-entrance-exam"
RESCORE_IF_HIGHER_BUTTON_NAME = "rescore-entrance-exam-if-higher"
DELETE_STATE_BUTTON_NAME = "delete-entrance-exam-state"
BACKGROUND_TASKS_BUTTON_NAME = "entrance-exam-task-history"
TASK_HISTORY_TABLE_NAME = "entrance-exam-task-history-table"
@property
def skip_entrance_exam_button(self):
"""
clicks background task history button.
Return Let Student Skip Entrance Exam button.
"""
return self.background_task_history_button.click()
return self.q(css='{} input[name=skip-entrance-exam]'.format(self.CONTAINER))
def set_student_email(self, email_address):
@property
def top_notification(self):
"""
Sets given email address as value of student email address/username input box.
Returns show background task history for student button.
"""
input_box = self.student_email_input.first.results[0]
input_box.send_keys(email_address)
return self.q(css='{} .request-response-error'.format(self.CONTAINER)).first
def set_problem_to_rescore(self, problem_locator):
def are_all_buttons_visible(self):
"""
Sets the problem for which to rescore/reset all scores.
Returns whether all buttons related to entrance exams
are visible.
"""
input_box = self.rescore_problem_input.first.results[0]
input_box.send_keys(problem_locator)
return (
self.student_email_or_username_input.is_present() and
self.reset_attempts_button.is_present() and
self.rescore_button.is_present() and
self.rescore_if_higher_button.is_present() and
self.delete_state_button.is_present() and
self.task_history_button.is_present()
)
class CertificatesPage(PageObject):
......
......@@ -107,6 +107,15 @@ class StaffDebugPage(PageObject):
self.q(css='input[id^=sd_fu_]').first.fill(user)
self.q(css='.staff-modal .staff-debug-rescore').click()
def rescore_if_higher(self, user=None):
"""
This clicks on the reset attempts link with an optionally
specified user.
"""
if user:
self.q(css='input[id^=sd_fu_]').first.fill(user)
self.q(css='.staff-modal .staff-debug-rescore-if-higher').click()
@property
def idash_msg(self):
"""
......
......@@ -169,6 +169,13 @@ class ContainerPage(PageObject, HelpMixin):
"""
return self.q(css='.action-publish').first
def publish(self):
"""
Publishes the container.
"""
self.publish_action.click()
self.wait_for_ajax()
def discard_changes(self):
"""
Discards draft changes (which will then re-render the page).
......
......@@ -353,17 +353,29 @@ def is_404_page(browser):
return 'Page not found (404)' in browser.find_element_by_tag_name('h1').text
def create_multiple_choice_problem(problem_name):
def create_multiple_choice_xml(correct_choice=2, num_choices=4):
"""
Return the Multiple Choice Problem Descriptor, given the name of the problem.
Return the Multiple Choice Problem XML, given the name of the problem.
"""
factory = MultipleChoiceResponseXMLFactory()
xml_data = factory.build_xml(
question_text='The correct answer is Choice 2',
choices=[False, False, True, False],
choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
# all choices are incorrect except for correct_choice
choices = [False for _ in range(num_choices)]
choices[correct_choice] = True
choice_names = ['choice_{}'.format(index) for index in range(num_choices)]
question_text = 'The correct answer is Choice {}'.format(correct_choice)
return MultipleChoiceResponseXMLFactory().build_xml(
question_text=question_text,
choices=choices,
choice_names=choice_names,
)
def create_multiple_choice_problem(problem_name):
"""
Return the Multiple Choice Problem Descriptor, given the name of the problem.
"""
xml_data = create_multiple_choice_xml()
return XBlockFixtureDesc(
'problem',
problem_name,
......
......@@ -14,7 +14,7 @@ from common.test.acceptance.pages.lms.auto_auth import AutoAuthPage
from common.test.acceptance.pages.studio.overview import CourseOutlinePage
from common.test.acceptance.pages.lms.create_mode import ModeCreationPage
from common.test.acceptance.pages.lms.courseware import CoursewarePage
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage, EntranceExamAdmin
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
from common.test.acceptance.pages.lms.dashboard import DashboardPage
from common.test.acceptance.pages.lms.problem import ProblemPage
......@@ -403,10 +403,17 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
@attr(shard=7)
@ddt.ddt
class EntranceExamGradeTest(BaseInstructorDashboardTest):
"""
Tests for Entrance exam specific student grading tasks.
"""
admin_buttons = (
'reset_attempts_button',
'rescore_button',
'rescore_if_higher_button',
'delete_state_button',
)
def setUp(self):
super(EntranceExamGradeTest, self).setUp()
......@@ -426,7 +433,7 @@ class EntranceExamGradeTest(BaseInstructorDashboardTest):
# go to the student admin page on the instructor dashboard
self.log_in_as_instructor()
self.student_admin_section = self.visit_instructor_dashboard().select_student_admin()
self.entrance_exam_admin = self.visit_instructor_dashboard().select_student_admin(EntranceExamAdmin)
def test_input_text_and_buttons_are_visible(self):
"""
......@@ -437,86 +444,57 @@ class EntranceExamGradeTest(BaseInstructorDashboardTest):
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())
self.assertTrue(self.entrance_exam_admin.are_all_buttons_visible())
def test_clicking_reset_student_attempts_button_without_email_shows_error(self):
@ddt.data(*admin_buttons)
def test_admin_button_without_email_shows_error(self, button_to_test):
"""
Scenario: Clicking on the Reset Student Attempts button without entering student email
Scenario: Clicking on the requested 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
When I click the requested 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()
getattr(self.entrance_exam_admin, button_to_test).click()
self.assertEqual(
'Please enter a student email address or username.',
self.student_admin_section.top_notification.text[0]
self.entrance_exam_admin.top_notification.text[0]
)
def test_clicking_reset_student_attempts_button_with_success(self):
@ddt.data(*admin_buttons)
def test_admin_button_with_success(self, button_to_test):
"""
Scenario: Clicking on the Reset Student Attempts button with valid student email
Scenario: Clicking on the requested 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
When I click the requested 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)
self.entrance_exam_admin.set_student_email_or_username(self.student_identifier)
getattr(self.entrance_exam_admin, button_to_test).click()
alert = get_modal_alert(self.entrance_exam_admin.browser)
alert.dismiss()
def test_clicking_rescore_submission_button_with_error(self):
@ddt.data(*admin_buttons)
def test_admin_button_with_error(self, button_to_test):
"""
Scenario: Clicking on the Rescore Student Submission button with email address or username
Scenario: Clicking on the requested 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
When I click the requested 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)
self.entrance_exam_admin.set_student_email_or_username('non_existing@example.com')
getattr(self.entrance_exam_admin, button_to_test).click()
self.entrance_exam_admin.wait_for_ajax()
self.assertGreater(len(self.entrance_exam_admin.top_notification.text[0]), 0)
def test_clicking_skip_entrance_exam_button_with_success(self):
def test_skip_entrance_exam_button_with_success(self):
"""
Scenario: Clicking on the Let Student Skip Entrance Exam button with
valid student email address or username should result in success prompt.
......@@ -526,17 +504,18 @@ class EntranceExamGradeTest(BaseInstructorDashboardTest):
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_skip_entrance_exam_button()
self.entrance_exam_admin.set_student_email_or_username(self.student_identifier)
self.entrance_exam_admin.skip_entrance_exam_button.click()
#first we have window.confirm
alert = get_modal_alert(self.student_admin_section.browser)
alert = get_modal_alert(self.entrance_exam_admin.browser)
alert.accept()
# then we have alert confirming action
alert = get_modal_alert(self.student_admin_section.browser)
alert = get_modal_alert(self.entrance_exam_admin.browser)
alert.dismiss()
def test_clicking_skip_entrance_exam_button_with_error(self):
def test_skip_entrance_exam_button_with_error(self):
"""
Scenario: Clicking on the Let Student Skip Entrance Exam button with
email address or username of a non existing student should result in error message.
......@@ -546,47 +525,17 @@ class EntranceExamGradeTest(BaseInstructorDashboardTest):
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_skip_entrance_exam_button()
self.entrance_exam_admin.set_student_email_or_username('non_existing@example.com')
self.entrance_exam_admin.skip_entrance_exam_button.click()
#first we have window.confirm
alert = get_modal_alert(self.student_admin_section.browser)
alert = get_modal_alert(self.entrance_exam_admin.browser)
alert.accept()
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)
self.entrance_exam_admin.wait_for_ajax()
self.assertGreater(len(self.entrance_exam_admin.top_notification.text[0]), 0)
def test_clicking_task_history_button_with_success(self):
def test_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.
......@@ -594,11 +543,11 @@ class EntranceExamGradeTest(BaseInstructorDashboardTest):
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
Then I should be shown a 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())
self.entrance_exam_admin.set_student_email_or_username(self.student_identifier)
self.entrance_exam_admin.task_history_button.click()
self.entrance_exam_admin.wait_for_task_history_table()
@attr(shard=7)
......
......@@ -116,8 +116,9 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = self._goto_staff_page().open_staff_debug_info()
staff_debug_page.reset_attempts()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully reset the attempts '
'for user {}'.format(self.USERNAME), msg)
self.assertEqual(
u'Successfully reset the attempts for user {}'.format(self.USERNAME), msg,
)
def test_delete_state_empty(self):
"""
......@@ -126,8 +127,9 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = self._goto_staff_page().open_staff_debug_info()
staff_debug_page.delete_state()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully deleted student state '
'for user {}'.format(self.USERNAME), msg)
self.assertEqual(
u'Successfully deleted student state for user {}'.format(self.USERNAME), msg,
)
def test_reset_attempts_state(self):
"""
......@@ -139,10 +141,11 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.reset_attempts()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully reset the attempts '
'for user {}'.format(self.USERNAME), msg)
self.assertEqual(
u'Successfully reset the attempts for user {}'.format(self.USERNAME), msg,
)
def test_rescore_state(self):
def test_rescore_problem(self):
"""
Rescore the student
"""
......@@ -152,7 +155,19 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.rescore()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully rescored problem for user STAFF_TESTER', msg)
self.assertEqual(u'Successfully rescored problem for user {}'.format(self.USERNAME), msg)
def test_rescore_problem_if_higher(self):
"""
Rescore the student
"""
staff_page = self._goto_staff_page()
staff_page.answer_problem()
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.rescore_if_higher()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully rescored problem to improve score for user {}'.format(self.USERNAME), msg)
def test_student_state_delete(self):
"""
......@@ -164,8 +179,7 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.delete_state()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully deleted student state '
'for user {}'.format(self.USERNAME), msg)
self.assertEqual(u'Successfully deleted student state for user {}'.format(self.USERNAME), msg)
def test_student_by_email(self):
"""
......@@ -177,8 +191,7 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.reset_attempts(self.EMAIL)
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully reset the attempts '
'for user {}'.format(self.EMAIL), msg)
self.assertEqual(u'Successfully reset the attempts for user {}'.format(self.EMAIL), msg)
def test_bad_student(self):
"""
......@@ -189,8 +202,7 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.delete_state('INVALIDUSER')
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Failed to delete student state. '
'User does not exist.', msg)
self.assertEqual(u'Failed to delete student state for user. User does not exist.', msg)
def test_reset_attempts_for_problem_loaded_via_ajax(self):
"""
......@@ -203,8 +215,7 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.reset_attempts()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully reset the attempts '
'for user {}'.format(self.USERNAME), msg)
self.assertEqual(u'Successfully reset the attempts for user {}'.format(self.USERNAME), msg)
def test_rescore_state_for_problem_loaded_via_ajax(self):
"""
......@@ -217,7 +228,7 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.rescore()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully rescored problem for user STAFF_TESTER', msg)
self.assertEqual(u'Successfully rescored problem for user {}'.format(self.USERNAME), msg)
def test_student_state_delete_for_problem_loaded_via_ajax(self):
"""
......@@ -230,8 +241,7 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.delete_state()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully deleted student state '
'for user {}'.format(self.USERNAME), msg)
self.assertEqual(u'Successfully deleted student state for user {}'.format(self.USERNAME), msg)
class CourseWithContentGroupsTest(StaffViewTest):
......
......@@ -3,19 +3,19 @@
End-to-end tests for the LMS that utilize the
progress page.
"""
from contextlib import contextmanager
import ddt
from ..helpers import UniqueCourseTest, auto_auth, create_multiple_choice_problem
from ..helpers import (
UniqueCourseTest, auto_auth, create_multiple_choice_problem, create_multiple_choice_xml, get_modal_alert
)
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...pages.common.logout import LogoutPage
from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.instructor_dashboard import InstructorDashboardPage
from ...pages.lms.instructor_dashboard import InstructorDashboardPage, StudentSpecificAdmin
from ...pages.lms.problem import ProblemPage
from ...pages.lms.progress import ProgressPage
from ...pages.lms.staff_view import StaffPage, StaffDebugPage
from ...pages.studio.component_editor import ComponentEditorView
from ...pages.studio.utils import type_in_codemirror
from ...pages.studio.overview import CourseOutlinePage
......@@ -56,13 +56,13 @@ class ProgressPageBaseTest(UniqueCourseTest):
self.course_info['display_name']
)
self.problem1 = create_multiple_choice_problem(self.PROBLEM_NAME)
self.problem2 = create_multiple_choice_problem(self.PROBLEM_NAME_2)
self.course_fix.add_children(
XBlockFixtureDesc('chapter', self.SECTION_NAME).add_children(
XBlockFixtureDesc('sequential', self.SUBSECTION_NAME).add_children(
XBlockFixtureDesc('vertical', self.UNIT_NAME).add_children(
create_multiple_choice_problem(self.PROBLEM_NAME),
create_multiple_choice_problem(self.PROBLEM_NAME_2)
)
XBlockFixtureDesc('vertical', self.UNIT_NAME).add_children(self.problem1, self.problem2)
)
)
).install()
......@@ -74,8 +74,14 @@ class ProgressPageBaseTest(UniqueCourseTest):
"""
Submit a correct answer to the problem.
"""
self._answer_problem(choice=2)
def _answer_problem(self, choice):
"""
Submit the given choice for the problem.
"""
self.courseware_page.go_to_sequential_position(1)
self.problem_page.click_choice('choice_choice_2')
self.problem_page.click_choice('choice_choice_{}'.format(choice))
self.problem_page.click_submit()
def _get_section_score(self):
......@@ -92,19 +98,6 @@ class ProgressPageBaseTest(UniqueCourseTest):
self.progress_page.visit()
return self.progress_page.scores(self.SECTION_NAME, self.SUBSECTION_NAME)
def _check_progress_page_with_scored_problem(self):
"""
Checks the progress page before and after answering
the course's first problem correctly.
"""
with self._logged_in_session():
self.assertEqual(self._get_problem_scores(), [(0, 1), (0, 1)])
self.assertEqual(self._get_section_score(), (0, 2))
self.courseware_page.visit()
self._answer_problem_correctly()
self.assertEqual(self._get_problem_scores(), [(1, 1), (0, 1)])
self.assertEqual(self._get_section_score(), (1, 2))
@contextmanager
def _logged_in_session(self, staff=False):
"""
......@@ -122,14 +115,6 @@ class ProgressPageBaseTest(UniqueCourseTest):
self.logout_page.visit()
class ProgressPageTest(ProgressPageBaseTest):
"""
Test that the progress page reports scores from completed assessments.
"""
def test_progress_page_shows_scored_problems(self):
self._check_progress_page_with_scored_problem()
@ddt.ddt
class PersistentGradesTest(ProgressPageBaseTest):
"""
......@@ -145,104 +130,132 @@ class PersistentGradesTest(ProgressPageBaseTest):
Adds a unit to the subsection, which
should not affect a persisted subsection grade.
"""
with self._logged_in_session(staff=True):
self.course_outline.visit()
subsection = self.course_outline.section(self.SECTION_NAME).subsection(self.SUBSECTION_NAME)
subsection.expand_subsection()
subsection.add_unit()
self.course_outline.visit()
subsection = self.course_outline.section(self.SECTION_NAME).subsection(self.SUBSECTION_NAME)
subsection.expand_subsection()
subsection.add_unit()
subsection.publish()
def _set_staff_lock_on_subsection(self, locked):
"""
Sets staff lock for a subsection, which should hide the
subsection score from students on the progress page.
"""
with self._logged_in_session(staff=True):
self.course_outline.visit()
subsection = self.course_outline.section_at(0).subsection_at(0)
subsection.set_staff_lock(locked)
self.assertEqual(subsection.has_staff_lock_warning, locked)
self.course_outline.visit()
subsection = self.course_outline.section_at(0).subsection_at(0)
subsection.set_staff_lock(locked)
self.assertEqual(subsection.has_staff_lock_warning, locked)
def _get_problem_in_studio(self):
"""
Returns the editable problem component in studio,
along with its container unit, so any changes can
be published.
"""
self.course_outline.visit()
self.course_outline.section_at(0).subsection_at(0).expand_subsection()
unit = self.course_outline.section_at(0).subsection_at(0).unit(self.UNIT_NAME).go_to()
component = unit.xblocks[1]
return unit, component
def _change_weight_for_problem(self):
"""
Changes the weight of the problem, which should not affect
persisted grades.
"""
with self._logged_in_session(staff=True):
self.course_outline.visit()
self.course_outline.section_at(0).subsection_at(0).expand_subsection()
unit = self.course_outline.section_at(0).subsection_at(0).unit(self.UNIT_NAME).go_to()
component = unit.xblocks[1]
component.edit()
component_editor = ComponentEditorView(self.browser, component.locator)
component_editor.set_field_value_and_save('Problem Weight', 5)
def _edit_problem_content(self):
unit, component = self._get_problem_in_studio()
component.edit()
component_editor = ComponentEditorView(self.browser, component.locator)
component_editor.set_field_value_and_save('Problem Weight', 5)
unit.publish()
def _change_correct_answer_for_problem(self, new_correct_choice=1):
"""
Replaces the content of a problem with other html.
Should not affect persisted grades.
Changes the correct answer of the problem.
"""
with self._logged_in_session(staff=True):
self.course_outline.visit()
self.course_outline.section_at(0).subsection_at(0).expand_subsection()
unit = self.course_outline.section_at(0).subsection_at(0).unit(self.UNIT_NAME).go_to()
component = unit.xblocks[1]
modal = component.edit()
unit, component = self._get_problem_in_studio()
modal = component.edit()
modified_content = create_multiple_choice_xml(correct_choice=new_correct_choice)
# Set content in the CodeMirror editor.
modified_content = "<p>modified content</p>"
type_in_codemirror(self, 0, modified_content)
modal.q(css='.action-save').click()
type_in_codemirror(self, 0, modified_content)
modal.q(css='.action-save').click()
unit.publish()
def _delete_student_state_for_problem(self):
def _student_admin_action_for_problem(self, action_button, has_cancellable_alert=False):
"""
As staff, clicks the "delete student state" button,
deleting the student user's state for the problem.
"""
with self._logged_in_session(staff=True):
self.instructor_dashboard_page.visit()
student_admin_section = self.instructor_dashboard_page.select_student_admin(StudentSpecificAdmin)
student_admin_section.set_student_email_or_username(self.USERNAME)
student_admin_section.set_problem_location(self.problem1.locator)
getattr(student_admin_section, action_button).click()
if has_cancellable_alert:
alert = get_modal_alert(student_admin_section.browser)
alert.accept()
alert = get_modal_alert(student_admin_section.browser)
alert.dismiss()
return student_admin_section
def test_progress_page_shows_scored_problems(self):
"""
Checks the progress page before and after answering
the course's first problem correctly.
"""
with self._logged_in_session():
self.assertEqual(self._get_problem_scores(), [(0, 1), (0, 1)])
self.assertEqual(self._get_section_score(), (0, 2))
self.courseware_page.visit()
staff_page = StaffPage(self.browser, self.course_id)
self.assertEqual(staff_page.staff_view_mode, "Staff")
staff_page.q(css='a.instructor-info-action').nth(1).click()
staff_debug_page = StaffDebugPage(self.browser)
staff_debug_page.wait_for_page()
staff_debug_page.delete_state(self.USERNAME)
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully deleted student state for user {0}'.format(self.USERNAME), msg)
self._answer_problem_correctly()
self.assertEqual(self._get_problem_scores(), [(1, 1), (0, 1)])
self.assertEqual(self._get_section_score(), (1, 2))
@ddt.data(
_edit_problem_content,
_change_correct_answer_for_problem,
_change_subsection_structure,
_change_weight_for_problem
)
def test_content_changes_do_not_change_score(self, edit):
with self._logged_in_session():
self._check_progress_page_with_scored_problem()
self.courseware_page.visit()
self._answer_problem_correctly()
edit(self)
with self._logged_in_session(staff=True):
edit(self)
with self._logged_in_session():
self.assertEqual(self._get_problem_scores(), [(1, 1), (0, 1)])
self.assertEqual(self._get_section_score(), (1, 2))
def test_visibility_change_does_affect_score(self):
def test_visibility_change_affects_score(self):
with self._logged_in_session():
self._check_progress_page_with_scored_problem()
self.courseware_page.visit()
self._answer_problem_correctly()
self._set_staff_lock_on_subsection(True)
with self._logged_in_session(staff=True):
self._set_staff_lock_on_subsection(True)
with self._logged_in_session():
self.assertEqual(self._get_problem_scores(), None)
self.assertEqual(self._get_section_score(), None)
self._set_staff_lock_on_subsection(False)
with self._logged_in_session(staff=True):
self._set_staff_lock_on_subsection(False)
with self._logged_in_session():
self.assertEqual(self._get_problem_scores(), [(1, 1), (0, 1)])
self.assertEqual(self._get_section_score(), (1, 2))
def test_progress_page_updates_when_student_state_deleted(self):
self._check_progress_page_with_scored_problem()
self._delete_student_state_for_problem()
def test_delete_student_state_affects_score(self):
with self._logged_in_session():
self.courseware_page.visit()
self._answer_problem_correctly()
with self._logged_in_session(staff=True):
self._student_admin_action_for_problem('delete_state_button', has_cancellable_alert=True)
with self._logged_in_session():
self.assertEqual(self._get_problem_scores(), [(0, 1), (0, 1)])
self.assertEqual(self._get_section_score(), (0, 2))
......
......@@ -172,8 +172,9 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
# block passed in, so we pass in a child of the gating block
lms_gating_api.evaluate_prerequisite(
self.course,
UsageKey.from_string(unicode(self.blocks[gating_block_child].location)),
self.user.id)
self.blocks[gating_block_child],
self.user.id,
)
with self.assertNumQueries(2):
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL)
......
......@@ -16,7 +16,6 @@ from edxmako.shortcuts import render_to_string
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from static_replace import replace_static_urls
from xmodule.modulestore import ModuleStoreEnum
from xmodule.x_module import STUDENT_VIEW
from courseware.access import has_access
......
......@@ -1007,3 +1007,20 @@ def set_score(user_id, usage_key, score, max_score):
student_module.grade = score
student_module.max_grade = max_score
student_module.save()
def get_score(user_id, usage_key):
"""
Get the score and max_score for the specified user and xblock usage.
Returns None if not found.
"""
try:
student_module = StudentModule.objects.get(
student_id=user_id,
module_state_key=usage_key,
course_id=usage_key.course_key,
)
except StudentModule.DoesNotExist:
return None
else:
return student_module.grade, student_module.max_grade
......@@ -8,7 +8,6 @@ import logging
from collections import OrderedDict
from functools import partial
import dogstats_wrapper as dog_stats_api
import newrelic.agent
from capa.xqueue_interface import XQueueInterface
from django.conf import settings
......@@ -18,7 +17,6 @@ from django.core.context_processors import csrf
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponse
from django.test.client import RequestFactory
from django.views.decorators.csrf import csrf_exempt
from edx_proctoring.services import ProctoringService
from eventtracking import tracker
......@@ -34,7 +32,6 @@ from xblock.reference.plugins import FSService
import static_replace
from courseware.access import has_access, get_user_role
from courseware.entrance_exams import (
get_entrance_exam_score,
user_must_complete_entrance_exam,
user_has_passed_entrance_exam
)
......@@ -44,14 +41,14 @@ from courseware.masquerade import (
is_masquerading_as_specific_student,
setup_masquerade,
)
from courseware.model_data import DjangoKeyValueStore, FieldDataCache, set_score
from lms.djangoapps.grades.signals.signals import SCORE_CHANGED
from courseware.model_data import DjangoKeyValueStore, FieldDataCache
from edxmako.shortcuts import render_to_string
from lms.djangoapps.grades.signals.signals import SCORE_PUBLISHED
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
from openedx.core.djangoapps.bookmarks.services import BookmarksService
from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem
from lms.djangoapps.verify_student.services import VerificationService, ReverificationService
from openedx.core.djangoapps.bookmarks.services import BookmarksService
from openedx.core.djangoapps.credit.services import CreditService
from openedx.core.djangoapps.util.user_utils import SystemUser
from openedx.core.lib.xblock_utils import (
......@@ -466,89 +463,17 @@ def get_module_system_for_user(user, student_data, # TODO # pylint: disable=to
course=course
)
def _fulfill_content_milestones(user, course_key, content_key):
"""
Internal helper to handle milestone fulfillments for the specified content module
"""
# Fulfillment Use Case: Entrance Exam
# If this module is part of an entrance exam, we'll need to see if the student
# has reached the point at which they can collect the associated milestone
if milestones_helpers.is_entrance_exams_enabled():
course = modulestore().get_course(course_key)
content = modulestore().get_item(content_key)
entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', False)
in_entrance_exam = getattr(content, 'in_entrance_exam', False)
if entrance_exam_enabled and in_entrance_exam:
# We don't have access to the true request object in this context, but we can use a mock
request = RequestFactory().request()
request.user = user
exam_pct = get_entrance_exam_score(request, course)
if exam_pct >= course.entrance_exam_minimum_score_pct:
exam_key = UsageKey.from_string(course.entrance_exam_id)
relationship_types = milestones_helpers.get_milestone_relationship_types()
content_milestones = milestones_helpers.get_course_content_milestones(
course_key,
exam_key,
relationship=relationship_types['FULFILLS']
)
# Add each milestone to the user's set...
user = {'id': request.user.id}
for milestone in content_milestones:
milestones_helpers.add_user_milestone(user, milestone)
def handle_grade_event(block, event_type, event): # pylint: disable=unused-argument
"""
Manages the workflow for recording and updating of student module grade state
"""
user_id = user.id
grade = event.get('value')
max_grade = event.get('max_value')
set_score(
user_id,
descriptor.location,
grade,
max_grade,
)
# Bin score into range and increment stats
score_bucket = get_score_bucket(grade, max_grade)
tags = [
u"org:{}".format(course_id.org),
u"course:{}".format(course_id),
u"score_bucket:{0}".format(score_bucket)
]
if grade_bucket_type is not None:
tags.append('type:%s' % grade_bucket_type)
dog_stats_api.increment("lms.courseware.question_answered", tags=tags)
# Cycle through the milestone fulfillment scenarios to see if any are now applicable
# thanks to the updated grading information that was just submitted
_fulfill_content_milestones(
user,
course_id,
descriptor.location,
)
# Send a signal out to any listeners who are waiting for score change
# events.
SCORE_CHANGED.send(
sender=None,
points_possible=event['max_value'],
points_earned=event['value'],
user_id=user.id,
course_id=unicode(course_id),
usage_id=unicode(descriptor.location)
)
def publish(block, event_type, event):
"""A function that allows XModules to publish events."""
if event_type == 'grade' and not is_masquerading_as_specific_student(user, course_id):
handle_grade_event(block, event_type, event)
SCORE_PUBLISHED.send(
sender=None,
block=block,
user=user,
raw_earned=event['value'],
raw_possible=event['max_value'],
only_if_higher=event.get('only_if_higher'),
)
else:
aside_context = {}
for aside in block.runtime.get_asides(block):
......@@ -1138,20 +1063,6 @@ def xblock_view(request, course_id, usage_id, view_name):
})
def get_score_bucket(grade, max_grade):
"""
Function to split arbitrary score ranges into 3 buckets.
Used with statsd tracking.
"""
score_bucket = "incorrect"
if grade > 0 and grade < max_grade:
score_bucket = "partial"
elif grade == max_grade:
score_bucket = "correct"
return score_bucket
def _check_files_limits(files):
"""
Check if the files in a request are under the limits defined by
......
"""
Helpers for courseware tests.
"""
import crum
import json
from django.contrib.auth.models import User
......@@ -19,6 +23,9 @@ def get_request_for_user(user):
request.is_secure = lambda: True
request.get_host = lambda: "edx.org"
request.method = 'GET'
request.GET = {}
request.POST = {}
crum.set_current_request(request)
return request
......
......@@ -253,14 +253,6 @@ class ModuleRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
self.dispatch
)
def test_get_score_bucket(self):
self.assertEquals(render.get_score_bucket(0, 10), 'incorrect')
self.assertEquals(render.get_score_bucket(1, 10), 'partial')
self.assertEquals(render.get_score_bucket(10, 10), 'correct')
# get_score_bucket calls error cases 'incorrect'
self.assertEquals(render.get_score_bucket(11, 10), 'incorrect')
self.assertEquals(render.get_score_bucket(-1, 10), 'incorrect')
def test_anonymous_handle_xblock_callback(self):
dispatch_url = reverse(
'xblock_handler',
......@@ -1839,7 +1831,7 @@ class TestXmoduleRuntimeEvent(TestSubmittingProblems):
self.assertIsNone(student_module.grade)
self.assertIsNone(student_module.max_grade)
@patch('courseware.module_render.SCORE_CHANGED.send')
@patch('lms.djangoapps.grades.signals.handlers.SCORE_CHANGED.send')
def test_score_change_signal(self, send_mock):
"""Test that a Django signal is generated when a score changes"""
self.set_module_grade_using_publish(self.grade_dict)
......@@ -1849,7 +1841,8 @@ class TestXmoduleRuntimeEvent(TestSubmittingProblems):
'points_earned': self.grade_dict['value'],
'user_id': self.student_user.id,
'course_id': unicode(self.course.id),
'usage_id': unicode(self.problem.location)
'usage_id': unicode(self.problem.location),
'only_if_higher': None,
}
send_mock.assert_called_with(**expected_signal_kwargs)
......
......@@ -153,7 +153,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase, Probl
self.student_user = User.objects.get(email=self.student)
self.factory = RequestFactory()
# Disable the score change signal to prevent other components from being pulled into tests.
self.score_changed_signal_patch = patch('courseware.module_render.SCORE_CHANGED.send')
self.score_changed_signal_patch = patch('lms.djangoapps.grades.signals.handlers.SCORE_CHANGED.send')
self.score_changed_signal_patch.start()
def tearDown(self):
......
"""
API for the gating djangoapp
"""
import logging
import json
from collections import defaultdict
from django.contrib.auth.models import User
from xmodule.modulestore.django import modulestore
from django.test.client import RequestFactory
import json
import logging
from openedx.core.lib.gating import api as gating_api
from opaque_keys.edx.keys import UsageKey
from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_score
from lms.djangoapps.grades.module_grades import get_module_score
from util import milestones_helpers
log = logging.getLogger(__name__)
......@@ -34,7 +37,7 @@ def _get_xblock_parent(xblock, category=None):
@gating_api.gating_enabled(default=False)
def evaluate_prerequisite(course, prereq_content_key, user_id):
def evaluate_prerequisite(course, block, user_id):
"""
Finds the parent subsection of the content in the course and evaluates
any milestone relationships attached to that subsection. If the calculated
......@@ -42,15 +45,14 @@ def evaluate_prerequisite(course, prereq_content_key, user_id):
dependent subsections, the related milestone will be fulfilled for the user.
Arguments:
user_id (int): ID of User for which evaluation should occur
course (CourseModule): The course
prereq_content_key (UsageKey): The prerequisite content usage key
user_id (int): ID of User for which evaluation should occur
Returns:
None
"""
xblock = modulestore().get_item(prereq_content_key)
sequential = _get_xblock_parent(xblock, 'sequential')
sequential = _get_xblock_parent(block, 'sequential')
if sequential:
prereq_milestone = gating_api.get_gating_milestone(
course.id,
......@@ -83,3 +85,32 @@ def evaluate_prerequisite(course, prereq_content_key, user_id):
milestones_helpers.add_user_milestone({'id': user_id}, prereq_milestone)
else:
milestones_helpers.remove_user_milestone({'id': user_id}, prereq_milestone)
def evaluate_entrance_exam(course, block, user_id):
"""
Update milestone fulfillments for the specified content module
"""
# Fulfillment Use Case: Entrance Exam
# If this module is part of an entrance exam, we'll need to see if the student
# has reached the point at which they can collect the associated milestone
if milestones_helpers.is_entrance_exams_enabled():
entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', False)
in_entrance_exam = getattr(block, 'in_entrance_exam', False)
if entrance_exam_enabled and in_entrance_exam:
# We don't have access to the true request object in this context, but we can use a mock
request = RequestFactory().request()
request.user = User.objects.get(id=user_id)
exam_pct = get_entrance_exam_score(request, course)
if exam_pct >= course.entrance_exam_minimum_score_pct:
exam_key = UsageKey.from_string(course.entrance_exam_id)
relationship_types = milestones_helpers.get_milestone_relationship_types()
content_milestones = milestones_helpers.get_course_content_milestones(
course.id,
exam_key,
relationship=relationship_types['FULFILLS']
)
# Add each milestone to the user's set...
user = {'id': request.user.id}
for milestone in content_milestones:
milestones_helpers.add_user_milestone(user, milestone)
......@@ -2,10 +2,11 @@
Signal handlers for the gating djangoapp
"""
from django.dispatch import receiver
from gating import api as gating_api
from lms.djangoapps.grades.signals.signals import SCORE_CHANGED
from opaque_keys.edx.keys import CourseKey, UsageKey
from xmodule.modulestore.django import modulestore
from lms.djangoapps.grades.signals.signals import SCORE_CHANGED
from gating import api as gating_api
@receiver(SCORE_CHANGED)
......@@ -22,9 +23,6 @@ def handle_score_changed(**kwargs):
None
"""
course = modulestore().get_course(CourseKey.from_string(kwargs.get('course_id')))
if course.enable_subsection_gating:
gating_api.evaluate_prerequisite(
course,
UsageKey.from_string(kwargs.get('usage_id')),
kwargs.get('user_id'),
)
block = modulestore().get_item(UsageKey.from_string(kwargs.get('usage_id')))
gating_api.evaluate_prerequisite(course, block, kwargs.get('user_id'))
gating_api.evaluate_entrance_exam(course, block, kwargs.get('user_id'))
......@@ -134,7 +134,7 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
self._setup_gating_milestone(50)
mock_module_score.return_value = module_score
evaluate_prerequisite(self.course, self.prob1.location, self.user.id)
evaluate_prerequisite(self.course, self.prob1, self.user.id)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
@patch('gating.api.log.warning')
......@@ -147,7 +147,7 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
self._setup_gating_milestone(None)
mock_module_score.return_value = module_score
evaluate_prerequisite(self.course, self.prob1.location, self.user.id)
evaluate_prerequisite(self.course, self.prob1, self.user.id)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
self.assertTrue(mock_log.called)
......@@ -155,14 +155,14 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
def test_orphaned_xblock(self, mock_module_score):
""" Test test_orphaned_xblock """
evaluate_prerequisite(self.course, self.prob2.location, self.user.id)
evaluate_prerequisite(self.course, self.prob2, self.user.id)
self.assertFalse(mock_module_score.called)
@patch('gating.api.get_module_score')
def test_no_prerequisites(self, mock_module_score):
""" Test test_no_prerequisites """
evaluate_prerequisite(self.course, self.prob1.location, self.user.id)
evaluate_prerequisite(self.course, self.prob1, self.user.id)
self.assertFalse(mock_module_score.called)
@patch('gating.api.get_module_score')
......@@ -172,5 +172,5 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
# Setup gating milestones data
gating_api.add_prerequisite(self.course.id, self.seq1.location)
evaluate_prerequisite(self.course, self.prob1.location, self.user.id)
evaluate_prerequisite(self.course, self.prob1, self.user.id)
self.assertFalse(mock_module_score.called)
......@@ -20,7 +20,7 @@ class TestHandleScoreChanged(ModuleStoreTestCase):
super(TestHandleScoreChanged, self).setUp()
self.course = CourseFactory.create(org='TestX', number='TS01', run='2016_Q1')
self.user = UserFactory.create()
self.test_usage_key = UsageKey.from_string('i4x://the/content/key/12345678')
self.test_usage_key = self.course.location
@patch('gating.signals.gating_api.evaluate_prerequisite')
def test_gating_enabled(self, mock_evaluate):
......@@ -35,7 +35,7 @@ class TestHandleScoreChanged(ModuleStoreTestCase):
course_id=unicode(self.course.id),
usage_id=unicode(self.test_usage_key)
)
mock_evaluate.assert_called_with(self.course, self.test_usage_key, self.user.id) # pylint: disable=no-member
mock_evaluate.assert_called_with(self.course, self.course, self.user.id) # pylint: disable=no-member
@patch('gating.signals.gating_api.evaluate_prerequisite')
def test_gating_disabled(self, mock_evaluate):
......
......@@ -11,6 +11,7 @@ from courseware.model_data import ScoresClient
from lms.djangoapps.grades.scores import get_score, possibly_scored
from lms.djangoapps.grades.models import BlockRecord, PersistentSubsectionGrade
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from openedx.core.lib.grade_utils import is_score_higher
from student.models import anonymous_id_for_user, User
from submissions import api as submissions_api
from traceback import format_exc
......@@ -74,6 +75,7 @@ class SubsectionGrade(object):
self.all_total, self.graded_total = graders.aggregate_scores(self.scores, self.display_name, self.location)
self._log_event(log.debug, u"init_from_structure", student)
return self
def init_from_model(self, student, model, course_structure, submissions_scores, csm_scores):
"""
......@@ -97,6 +99,7 @@ class SubsectionGrade(object):
module_id=self.location,
)
self._log_event(log.debug, u"init_from_model", student)
return self
@classmethod
def bulk_create_models(cls, student, subsection_grades, course_key):
......@@ -222,10 +225,9 @@ class SubsectionGradeFactory(object):
)
block_structure = self._get_block_structure(block_structure)
subsection_grade = self._get_saved_grade(subsection, block_structure)
subsection_grade = self._get_bulk_cached_grade(subsection, block_structure)
if not subsection_grade:
subsection_grade = SubsectionGrade(subsection, self.course)
subsection_grade.init_from_structure(
subsection_grade = SubsectionGrade(subsection, self.course).init_from_structure(
self.student, block_structure, self._submissions_scores, self._csm_scores,
)
if PersistentGradesEnabledFlag.feature_enabled(self.course.id):
......@@ -247,7 +249,7 @@ class SubsectionGradeFactory(object):
SubsectionGrade.bulk_create_models(self.student, self._unsaved_subsection_grades, self.course.id)
self._unsaved_subsection_grades = []
def update(self, subsection, block_structure=None):
def update(self, subsection, block_structure=None, only_if_higher=None):
"""
Updates the SubsectionGrade object for the student and subsection.
"""
......@@ -259,14 +261,30 @@ class SubsectionGradeFactory(object):
self._log_event(log.warning, u"update, subsection: {}".format(subsection.location))
block_structure = self._get_block_structure(block_structure)
subsection_grade = SubsectionGrade(subsection, self.course)
subsection_grade.init_from_structure(
self.student, block_structure, self._submissions_scores, self._csm_scores
calculated_grade = SubsectionGrade(subsection, self.course).init_from_structure(
self.student, block_structure, self._submissions_scores, self._csm_scores,
)
grade_model = subsection_grade.update_or_create_model(self.student)
if only_if_higher:
try:
grade_model = PersistentSubsectionGrade.read_grade(self.student.id, subsection.location)
except PersistentSubsectionGrade.DoesNotExist:
pass
else:
orig_subsection_grade = SubsectionGrade(subsection, self.course).init_from_model(
self.student, grade_model, block_structure, self._submissions_scores, self._csm_scores,
)
if not is_score_higher(
orig_subsection_grade.graded_total.earned,
orig_subsection_grade.graded_total.possible,
calculated_grade.graded_total.earned,
calculated_grade.graded_total.possible,
):
return orig_subsection_grade
grade_model = calculated_grade.update_or_create_model(self.student)
self._update_saved_subsection_grade(subsection.location, grade_model)
return subsection_grade
return calculated_grade
@lazy
def _csm_scores(self):
......@@ -286,33 +304,34 @@ class SubsectionGradeFactory(object):
anonymous_user_id = anonymous_id_for_user(self.student, self.course.id)
return submissions_api.get_scores(unicode(self.course.id), anonymous_user_id)
def _get_saved_grade(self, subsection, block_structure): # pylint: disable=unused-argument
def _get_bulk_cached_grade(self, subsection, block_structure): # pylint: disable=unused-argument
"""
Returns the saved grade for the student and subsection.
Returns the student's SubsectionGrade for the subsection,
while caching the results of a bulk retrieval for the
course, for future access of other subsections.
Returns None if not found.
"""
if not PersistentGradesEnabledFlag.feature_enabled(self.course.id):
return
saved_subsection_grade = self._get_saved_subsection_grade(subsection.location)
if saved_subsection_grade:
subsection_grade = SubsectionGrade(subsection, self.course)
subsection_grade.init_from_model(
self.student, saved_subsection_grade, block_structure, self._submissions_scores, self._csm_scores,
saved_subsection_grades = self._get_bulk_cached_subsection_grades()
subsection_grade = saved_subsection_grades.get(subsection.location)
if subsection_grade:
return SubsectionGrade(subsection, self.course).init_from_model(
self.student, subsection_grade, block_structure, self._submissions_scores, self._csm_scores,
)
return subsection_grade
def _get_saved_subsection_grade(self, subsection_usage_key):
def _get_bulk_cached_subsection_grades(self):
"""
Returns the saved value of the subsection grade for
the given subsection usage key, caching the value.
Returns None if not found.
Returns and caches (for future access) the results of
a bulk retrieval of all subsection grades in the course.
"""
if self._cached_subsection_grades is None:
self._cached_subsection_grades = {
record.full_usage_key: record
for record in PersistentSubsectionGrade.bulk_read_grades(self.student.id, self.course.id)
}
return self._cached_subsection_grades.get(subsection_usage_key)
return self._cached_subsection_grades
def _update_saved_subsection_grade(self, subsection_usage_key, subsection_model):
"""
......
......@@ -5,12 +5,15 @@ Grades related signals.
from django.dispatch import receiver
from logging import getLogger
from courseware.model_data import get_score, set_score
from openedx.core.lib.grade_utils import is_score_higher
from student.models import user_by_anonymous_id
from submissions.models import score_set, score_reset
from .signals import SCORE_CHANGED
from .signals import SCORE_CHANGED, SCORE_PUBLISHED
from ..tasks import recalculate_subsection_grade
log = getLogger(__name__)
......@@ -40,8 +43,8 @@ def submissions_score_set_handler(sender, **kwargs): # pylint: disable=unused-a
SCORE_CHANGED.send(
sender=None,
points_possible=points_possible,
points_earned=points_earned,
points_possible=points_possible,
user_id=user.id,
course_id=course_id,
usage_id=usage_id
......@@ -70,17 +73,61 @@ def submissions_score_reset_handler(sender, **kwargs): # pylint: disable=unused
SCORE_CHANGED.send(
sender=None,
points_possible=0,
points_earned=0,
points_possible=0,
user_id=user.id,
course_id=course_id,
usage_id=usage_id
)
@receiver(SCORE_PUBLISHED)
def score_published_handler(sender, block, user, raw_earned, raw_possible, only_if_higher, **kwargs): # pylint: disable=unused-argument
"""
Handles whenever a block's score is published.
Returns whether the score was actually updated.
"""
update_score = True
if only_if_higher:
previous_score = get_score(user.id, block.location)
if previous_score:
prev_raw_earned, prev_raw_possible = previous_score # pylint: disable=unpacking-non-sequence
if not is_score_higher(prev_raw_earned, prev_raw_possible, raw_earned, raw_possible):
update_score = False
log.warning(
u"Grades: Rescore is not higher than previous: "
u"user: {}, block: {}, previous: {}/{}, new: {}/{} ".format(
user, block.location, prev_raw_earned, prev_raw_possible, raw_earned, raw_possible,
)
)
if update_score:
set_score(user.id, block.location, raw_earned, raw_possible)
SCORE_CHANGED.send(
sender=None,
points_earned=raw_earned,
points_possible=raw_possible,
user_id=user.id,
course_id=unicode(block.location.course_key),
usage_id=unicode(block.location),
only_if_higher=only_if_higher,
)
return update_score
@receiver(SCORE_CHANGED)
def enqueue_update(sender, **kwargs): # pylint: disable=unused-argument
def enqueue_grade_update(sender, **kwargs): # pylint: disable=unused-argument
"""
Handles the SCORE_CHANGED signal by enqueueing an update operation to occur asynchronously.
"""
recalculate_subsection_grade.apply_async(args=(kwargs['user_id'], kwargs['course_id'], kwargs['usage_id']))
recalculate_subsection_grade.apply_async(
args=(
kwargs['user_id'],
kwargs['course_id'],
kwargs['usage_id'],
kwargs.get('only_if_higher'),
)
)
......@@ -12,10 +12,24 @@ from django.dispatch import Signal
# receives the same score).
SCORE_CHANGED = Signal(
providing_args=[
'points_possible', # Maximum score available for the exercise
'points_earned', # Score obtained by the user
'user_id', # Integer User ID
'course_id', # Unicode string representing the course
'usage_id' # Unicode string indicating the courseware instance
'usage_id', # Unicode string indicating the courseware instance
'points_earned', # Score obtained by the user
'points_possible', # Maximum score available for the exercise
'only_if_higher', # Boolean indicating whether updates should be
# made only if the new score is higher than previous.
]
)
SCORE_PUBLISHED = Signal(
providing_args=[
'block', # Course block object
'user', # User object
'raw_earned', # Score obtained by the user
'raw_possible', # Maximum score available for the exercise
'only_if_higher', # Boolean indicating whether updates should be
# made only if the new score is higher than previous.
]
)
......@@ -8,10 +8,10 @@ from django.contrib.auth.models import User
from django.db.utils import IntegrityError
from lms.djangoapps.course_blocks.api import get_course_blocks
from lms.djangoapps.courseware.courses import get_course_by_id
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import CourseLocator
from openedx.core.djangoapps.content.block_structure.api import get_course_in_cache
from xmodule.modulestore.django import modulestore
from .config.models import PersistentGradesEnabledFlag
from .transformer import GradesTransformer
......@@ -19,13 +19,16 @@ from .new.subsection_grade import SubsectionGradeFactory
@task(default_retry_delay=30, routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY)
def recalculate_subsection_grade(user_id, course_id, usage_id):
def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher):
"""
Updates a saved subsection grade.
This method expects the following parameters:
- user_id: serialized id of applicable User object
- course_id: Unicode string representing the course
- usage_id: Unicode string indicating the courseware instance
- only_if_higher: boolean indicating whether grades should
be updated only if the new grade is higher than the previous
value.
"""
course_key = CourseLocator.from_string(course_id)
if not PersistentGradesEnabledFlag.feature_enabled(course_key):
......@@ -35,7 +38,7 @@ def recalculate_subsection_grade(user_id, course_id, usage_id):
scored_block_usage_key = UsageKey.from_string(usage_id).replace(course_key=course_key)
collected_block_structure = get_course_in_cache(course_key)
course = get_course_by_id(course_key, depth=0)
course = modulestore().get_course(course_key, depth=0)
subsection_grade_factory = SubsectionGradeFactory(student, course, collected_block_structure)
subsections_to_update = collected_block_structure.get_transformer_block_field(
scored_block_usage_key,
......@@ -52,7 +55,9 @@ def recalculate_subsection_grade(user_id, course_id, usage_id):
collected_block_structure=collected_block_structure,
)
subsection_grade_factory.update(
transformed_subsection_structure[subsection_usage_key], transformed_subsection_structure
transformed_subsection_structure[subsection_usage_key],
transformed_subsection_structure,
only_if_higher,
)
except IntegrityError as exc:
raise recalculate_subsection_grade.retry(args=[user_id, course_id, usage_id], exc=exc)
......@@ -106,7 +106,7 @@ class TestCourseGradeFactory(GradeTestBase):
@ddt.ddt
class SubsectionGradeFactoryTest(GradeTestBase):
class TestSubsectionGradeFactory(GradeTestBase, ProblemSubmissionTestMixin):
"""
Tests for SubsectionGradeFactory functionality.
......@@ -115,17 +115,36 @@ class SubsectionGradeFactoryTest(GradeTestBase):
enable saving subsection grades blocks/enables that feature as expected.
"""
def assert_grade(self, grade, expected_earned, expected_possible):
"""
Asserts that the given grade object has the expected score.
"""
self.assertEqual(
(grade.all_total.earned, grade.all_total.possible),
(expected_earned, expected_possible),
)
def test_create(self):
"""
Tests to ensure that a persistent subsection grade is created, saved, then fetched on re-request.
Assuming the underlying score reporting methods work,
test that the score is calculated properly.
"""
with mock_get_score(1, 2):
grade = self.subsection_grade_factory.create(self.sequence)
self.assert_grade(grade, 1, 2)
def test_create_internals(self):
"""
Tests to ensure that a persistent subsection grade is
created, saved, then fetched on re-request.
"""
with patch(
'lms.djangoapps.grades.new.subsection_grade.PersistentSubsectionGrade.create_grade',
wraps=PersistentSubsectionGrade.create_grade
) as mock_create_grade:
with patch(
'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory._get_saved_grade',
wraps=self.subsection_grade_factory._get_saved_grade
'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory._get_bulk_cached_grade',
wraps=self.subsection_grade_factory._get_bulk_cached_grade
) as mock_get_saved_grade:
with self.assertNumQueries(14):
grade_a = self.subsection_grade_factory.create(self.sequence)
......@@ -143,6 +162,30 @@ class SubsectionGradeFactoryTest(GradeTestBase):
self.assertEqual(grade_a.url_name, grade_b.url_name)
self.assertEqual(grade_a.all_total, grade_b.all_total)
def test_update(self):
"""
Assuming the underlying score reporting methods work,
test that the score is calculated properly.
"""
with mock_get_score(1, 2):
grade = self.subsection_grade_factory.update(self.sequence)
self.assert_grade(grade, 1, 2)
def test_update_if_higher(self):
def verify_update_if_higher(mock_score, expected_grade):
"""
Updates the subsection grade and verifies the
resulting grade is as expected.
"""
with mock_get_score(*mock_score):
grade = self.subsection_grade_factory.update(self.sequence, only_if_higher=True)
self.assert_grade(grade, *expected_grade)
verify_update_if_higher((1, 2), (1, 2)) # previous value was non-existent
verify_update_if_higher((2, 4), (1, 2)) # previous value was equivalent
verify_update_if_higher((1, 4), (1, 2)) # previous value was greater
verify_update_if_higher((3, 4), (3, 4)) # previous value was less
@ddt.data(
(
'lms.djangoapps.grades.new.subsection_grade.SubsectionGrade.create_model',
......@@ -162,7 +205,8 @@ class SubsectionGradeFactoryTest(GradeTestBase):
with patch(underlying_method) as underlying:
underlying.side_effect = DatabaseError("I'm afraid I can't do that")
method_to_test(self)
# By making it this far, we implicitly assert "the factory method swallowed the exception correctly"
# By making it this far, we implicitly assert
# "the factory method swallowed the exception correctly"
self.assertTrue(
log_mock.warning.call_args_list[0].startswith("Persistent Grades: Persistence Error, falling back.")
)
......@@ -196,18 +240,10 @@ class SubsectionGradeTest(GradeTestBase):
Tests SubsectionGrade functionality.
"""
def test_compute(self):
"""
Assuming the underlying score reporting methods work, test that the score is calculated properly.
"""
with mock_get_score(1, 2):
grade = self.subsection_grade_factory.create(self.sequence)
self.assertEqual(grade.all_total.earned, 1)
self.assertEqual(grade.all_total.possible, 2)
def test_save_and_load(self):
"""
Test that grades are persisted to the database properly, and that loading saved grades returns the same data.
Test that grades are persisted to the database properly,
and that loading saved grades returns the same data.
"""
# Create a grade that *isn't* saved to the database
input_grade = SubsectionGrade(self.sequence, self.course)
......
......@@ -2,6 +2,7 @@
Tests for the functionality and infrastructure of grades tasks.
"""
from collections import OrderedDict
import ddt
from django.conf import settings
from django.db.utils import IntegrityError
......@@ -47,11 +48,12 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.sequential = ItemFactory.create(parent=self.chapter, category='sequential', display_name="Open Sequential")
self.problem = ItemFactory.create(parent=self.sequential, category='problem', display_name='problem')
self.score_changed_kwargs = {
'user_id': self.user.id,
'course_id': unicode(self.course.id),
'usage_id': unicode(self.problem.location),
}
self.score_changed_kwargs = OrderedDict([
('user_id', self.user.id),
('course_id', unicode(self.course.id)),
('usage_id', unicode(self.problem.location)),
('only_if_higher', None),
])
# this call caches the anonymous id on the user object, saving 4 queries in all happy path tests
_ = anonymous_id_for_user(self.user, self.course.id)
......@@ -67,13 +69,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
return_value=None
) as mock_task_apply:
SCORE_CHANGED.send(sender=None, **self.score_changed_kwargs)
mock_task_apply.assert_called_once_with(
args=(
self.score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'],
self.score_changed_kwargs['usage_id'],
)
)
mock_task_apply.assert_called_once_with(args=tuple(self.score_changed_kwargs.values()))
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_subsection_grade_updated(self, default_store):
......@@ -81,13 +77,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.set_up_course()
self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
with check_mongo_calls(2) and self.assertNumQueries(13):
recalculate_subsection_grade.apply(
args=(
self.score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'],
self.score_changed_kwargs['usage_id'],
)
)
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
def test_single_call_to_create_block_structure(self):
self.set_up_course()
......@@ -96,13 +86,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
'openedx.core.lib.block_structure.factory.BlockStructureFactory.create_from_cache',
return_value=None,
) as mock_block_structure_create:
recalculate_subsection_grade.apply(
args=(
self.score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'],
self.score_changed_kwargs['usage_id'],
)
)
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
self.assertEquals(mock_block_structure_create.call_count, 1)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
......@@ -113,13 +97,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
ItemFactory.create(parent=self.sequential, category='problem', display_name='problem2')
ItemFactory.create(parent=self.sequential, category='problem', display_name='problem3')
with check_mongo_calls(2) and self.assertNumQueries(13):
recalculate_subsection_grade.apply(
args=(
self.score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'],
self.score_changed_kwargs['usage_id'],
)
)
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_subsection_grades_not_enabled_on_course(self, default_store):
......@@ -127,13 +105,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.set_up_course(enable_subsection_grades=False)
self.assertFalse(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
with check_mongo_calls(2) and self.assertNumQueries(0):
recalculate_subsection_grade.apply(
args=(
self.score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'],
self.score_changed_kwargs['usage_id'],
)
)
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
@skip("Pending completion of TNL-5089")
@ddt.data(
......@@ -148,13 +120,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
with self.store.default_store(default_store):
self.set_up_course()
with check_mongo_calls(0) and self.assertNumQueries(3 if feature_flag else 2):
recalculate_subsection_grade.apply(
args=(
self.score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'],
self.score_changed_kwargs['usage_id'],
)
)
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry')
@patch('lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update')
......@@ -164,11 +130,5 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
"""
self.set_up_course()
mock_update.side_effect = IntegrityError("WHAMMY")
recalculate_subsection_grade.apply(
args=(
self.score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'],
self.score_changed_kwargs['usage_id'],
)
)
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
self.assertTrue(mock_retry.called)
......@@ -9,7 +9,7 @@ from logging import getLogger
import json
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module_for_descriptor
import courseware.module_render
from lms.djangoapps.course_blocks.transformers.utils import collect_unioned_set_field, get_field_on_block
from openedx.core.lib.block_structure.transformer import BlockStructureTransformer
from openedx.core.djangoapps.util.user_utils import SystemUser
......@@ -186,5 +186,5 @@ class GradesTransformer(BlockStructureTransformer):
for block_locator in block_structure.post_order_traversal():
block = block_structure.get_xblock(block_locator)
if getattr(block, 'has_score', False):
module = get_module_for_descriptor(user, request, block, cache, course_key)
module = courseware.module_render.get_module_for_descriptor(user, request, block, cache, course_key)
yield module
......@@ -3190,7 +3190,7 @@ class TestInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginEnrollmentTes
})
self.assertEqual(response.status_code, 400)
@patch('courseware.module_render.SCORE_CHANGED.send')
@patch('lms.djangoapps.grades.signals.handlers.SCORE_CHANGED.send')
def test_reset_student_attempts_delete(self, _mock_signal):
""" Test delete single student state. """
url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id.to_deprecated_string()})
......@@ -3469,6 +3469,15 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE
})
self.assertEqual(response.status_code, 200)
def test_rescore_entrance_exam_if_higher_all_student(self):
""" Test rescoring for all students only if higher. """
url = reverse('rescore_entrance_exam', kwargs={'course_id': unicode(self.course.id)})
response = self.client.post(url, {
'all_students': True,
'only_if_higher': 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)})
......
......@@ -378,7 +378,7 @@ class TestInstructorEnrollmentStudentModule(SharedModuleStoreTestCase):
reset_student_attempts(self.course_key, self.user, msk, requesting_user=self.user)
self.assertEqual(json.loads(module().state)['attempts'], 0)
@mock.patch('courseware.module_render.SCORE_CHANGED.send')
@mock.patch('lms.djangoapps.grades.signals.handlers.SCORE_CHANGED.send')
def test_delete_student_attempts(self, _mock_signal):
msk = self.course_key.make_usage_key('dummy', 'module')
original_state = json.dumps({'attempts': 32, 'otherstuff': 'alsorobots'})
......@@ -404,7 +404,7 @@ class TestInstructorEnrollmentStudentModule(SharedModuleStoreTestCase):
# Disable the score change signal to prevent other components from being
# pulled into tests.
@mock.patch('courseware.module_render.SCORE_CHANGED.send')
@mock.patch('lms.djangoapps.grades.signals.handlers.SCORE_CHANGED.send')
def test_delete_submission_scores(self, _mock_signal):
user = UserFactory()
problem_location = self.course_key.make_usage_key('dummy', 'module')
......
......@@ -49,7 +49,7 @@ class InstructorServiceTests(SharedModuleStoreTestCase):
state=json.dumps({'attempts': 2}),
)
@mock.patch('courseware.module_render.SCORE_CHANGED.send')
@mock.patch('lms.djangoapps.grades.signals.handlers.SCORE_CHANGED.send')
def test_reset_student_attempts_delete(self, _mock_signal):
"""
Test delete student state.
......
......@@ -602,8 +602,8 @@ def students_update_enrollment(request, course_id):
action = request.POST.get('action')
identifiers_raw = request.POST.get('identifiers')
identifiers = _split_input_list(identifiers_raw)
auto_enroll = request.POST.get('auto_enroll') in ['true', 'True', True]
email_students = request.POST.get('email_students') in ['true', 'True', True]
auto_enroll = _get_boolean_param(request, 'auto_enroll')
email_students = _get_boolean_param(request, 'email_students')
is_white_label = CourseMode.is_white_label(course_id)
reason = request.POST.get('reason')
if is_white_label:
......@@ -743,8 +743,8 @@ def bulk_beta_modify_access(request, course_id):
action = request.POST.get('action')
identifiers_raw = request.POST.get('identifiers')
identifiers = _split_input_list(identifiers_raw)
email_students = request.POST.get('email_students') in ['true', 'True', True]
auto_enroll = request.POST.get('auto_enroll') in ['true', 'True', True]
email_students = _get_boolean_param(request, 'email_students')
auto_enroll = _get_boolean_param(request, 'auto_enroll')
results = []
rolename = 'beta'
course = get_course_by_id(course_id)
......@@ -1939,8 +1939,8 @@ def reset_student_attempts(request, course_id):
student = None
if student_identifier is not None:
student = get_student_from_identifier(student_identifier)
all_students = request.POST.get('all_students', False) in ['true', 'True', True]
delete_module = request.POST.get('delete_module', False) in ['true', 'True', True]
all_students = _get_boolean_param(request, 'all_students')
delete_module = _get_boolean_param(request, 'delete_module')
# parameter combinations
if all_students and student:
......@@ -2027,8 +2027,8 @@ def reset_student_attempts_for_entrance_exam(request, course_id): # pylint: dis
student = None
if student_identifier is not None:
student = get_student_from_identifier(student_identifier)
all_students = request.POST.get('all_students', False) in ['true', 'True', True]
delete_module = request.POST.get('delete_module', False) in ['true', 'True', True]
all_students = _get_boolean_param(request, 'all_students')
delete_module = _get_boolean_param(request, 'delete_module')
# parameter combinations
if all_students and student:
......@@ -2092,7 +2092,8 @@ def rescore_problem(request, course_id):
if student_identifier is not None:
student = get_student_from_identifier(student_identifier)
all_students = request.POST.get('all_students') in ['true', 'True', True]
all_students = _get_boolean_param(request, 'all_students')
only_if_higher = _get_boolean_param(request, 'only_if_higher')
if not (problem_to_reset and (all_students or student)):
return HttpResponseBadRequest("Missing query parameters.")
......@@ -2107,19 +2108,26 @@ def rescore_problem(request, course_id):
except InvalidKeyError:
return HttpResponseBadRequest("Unable to parse problem id")
response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset
response_payload = {'problem_to_reset': problem_to_reset}
if student:
response_payload['student'] = student_identifier
lms.djangoapps.instructor_task.api.submit_rescore_problem_for_student(request, module_state_key, student)
response_payload['task'] = 'created'
lms.djangoapps.instructor_task.api.submit_rescore_problem_for_student(
request,
module_state_key,
student,
only_if_higher,
)
elif all_students:
lms.djangoapps.instructor_task.api.submit_rescore_problem_for_all_students(request, module_state_key)
response_payload['task'] = 'created'
lms.djangoapps.instructor_task.api.submit_rescore_problem_for_all_students(
request,
module_state_key,
only_if_higher,
)
else:
return HttpResponseBadRequest()
response_payload['task'] = 'created'
return JsonResponse(response_payload)
......@@ -2146,11 +2154,12 @@ def rescore_entrance_exam(request, course_id):
)
student_identifier = request.POST.get('unique_student_identifier', None)
only_if_higher = request.POST.get('only_if_higher', None)
student = None
if student_identifier is not None:
student = get_student_from_identifier(student_identifier)
all_students = request.POST.get('all_students') in ['true', 'True', True]
all_students = _get_boolean_param(request, 'all_students')
if not course.entrance_exam_id:
return HttpResponseBadRequest(
......@@ -2172,7 +2181,10 @@ def rescore_entrance_exam(request, course_id):
response_payload['student'] = student_identifier
else:
response_payload['student'] = _("All Students")
lms.djangoapps.instructor_task.api.submit_rescore_entrance_exam_for_student(request, entrance_exam_key, student)
lms.djangoapps.instructor_task.api.submit_rescore_entrance_exam_for_student(
request, entrance_exam_key, student, only_if_higher,
)
response_payload['task'] = 'created'
return JsonResponse(response_payload)
......@@ -3318,3 +3330,12 @@ def validate_request_data_and_get_certificate(certificate_invalidation, course_k
"username/email and the selected course are correct and try again."
).format(student=student.username, course=course_key.course))
return certificate
def _get_boolean_param(request, param_name):
"""
Returns the value of the boolean parameter with the given
name in the POST request. Handles translation from string
values to boolean values.
"""
return request.POST.get(param_name, False) in ['true', 'True', True]
......@@ -95,7 +95,7 @@ def get_entrance_exam_instructor_task_history(course_id, usage_key=None, student
# 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, only_if_higher=False): # pylint: disable=invalid-name
"""
Request a problem to be rescored as a background task.
......@@ -110,13 +110,14 @@ def submit_rescore_problem_for_student(request, usage_key, student): # pylint:
# check arguments: let exceptions return up to the caller.
check_arguments_for_rescoring(usage_key)
task_type = 'rescore_problem'
task_type = 'rescore_problem_if_higher' if only_if_higher else 'rescore_problem'
task_class = rescore_problem
task_input, task_key = encode_problem_and_student_input(usage_key, student)
task_input.update({'only_if_higher': only_if_higher})
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
def submit_rescore_problem_for_all_students(request, usage_key): # pylint: disable=invalid-name
def submit_rescore_problem_for_all_students(request, usage_key, only_if_higher=False): # pylint: disable=invalid-name
"""
Request a problem to be rescored as a background task.
......@@ -136,10 +137,11 @@ def submit_rescore_problem_for_all_students(request, usage_key): # pylint: disa
task_type = 'rescore_problem'
task_class = rescore_problem
task_input, task_key = encode_problem_and_student_input(usage_key)
task_input.update({'only_if_higher': only_if_higher})
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
def submit_rescore_entrance_exam_for_student(request, usage_key, student=None, only_if_higher=False): # pylint: disable=invalid-name
"""
Request entrance exam problems to be re-scored as a background task.
......@@ -161,6 +163,7 @@ def submit_rescore_entrance_exam_for_student(request, usage_key, student=None):
task_type = 'rescore_problem'
task_class = rescore_problem
task_input, task_key = encode_entrance_exam_and_student_input(usage_key, student)
task_input.update({'only_if_higher': only_if_higher})
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
......
......@@ -310,9 +310,9 @@ def perform_module_state_update(update_fcn, filter_fcn, _entry_id, course_id, ta
argument, which is the query being filtered, and returns the filtered version of the query.
The `update_fcn` is called on each StudentModule that passes the resulting filtering.
It is passed three arguments: the module_descriptor for the module pointed to by the
module_state_key, the particular StudentModule to update, and the xmodule_instance_args being
passed through. If the value returned by the update function evaluates to a boolean True,
It is passed four arguments: the module_descriptor for the module pointed to by the
module_state_key, the particular StudentModule to update, the xmodule_instance_args, and the task_input
being passed through. If the value returned by the update function evaluates to a boolean True,
the update is successful; False indicates the update on the particular student module failed.
A raised exception indicates a fatal condition -- that no other student modules should be considered.
......@@ -382,7 +382,7 @@ def perform_module_state_update(update_fcn, filter_fcn, _entry_id, course_id, ta
# 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)]):
update_status = update_fcn(module_descriptor, module_to_update)
update_status = update_fcn(module_descriptor, module_to_update, task_input)
if update_status == UPDATE_STATUS_SUCCEEDED:
# If the update_fcn returns true, then it performed some kind of work.
# Logging of failures is left to the update_fcn itself.
......@@ -470,7 +470,7 @@ def _get_module_instance_for_task(course_id, student, module_descriptor, xmodule
@outer_atomic
def rescore_problem_module_state(xmodule_instance_args, module_descriptor, student_module):
def rescore_problem_module_state(xmodule_instance_args, module_descriptor, student_module, task_input):
'''
Takes an XModule descriptor and a corresponding StudentModule object, and
performs rescoring on the student's problem submission.
......@@ -517,7 +517,7 @@ def rescore_problem_module_state(xmodule_instance_args, module_descriptor, stude
msg = "Specified problem does not support rescoring."
raise UpdateProblemModuleStateError(msg)
result = instance.rescore_problem()
result = instance.rescore_problem(only_if_higher=task_input['only_if_higher'])
instance.save()
if 'success' not in result:
# don't consider these fatal, but false means that the individual call didn't complete:
......@@ -559,7 +559,7 @@ def rescore_problem_module_state(xmodule_instance_args, module_descriptor, stude
@outer_atomic
def reset_attempts_module_state(xmodule_instance_args, _module_descriptor, student_module):
def reset_attempts_module_state(xmodule_instance_args, _module_descriptor, student_module, _task_input):
"""
Resets problem attempts to zero for specified `student_module`.
......@@ -586,7 +586,7 @@ def reset_attempts_module_state(xmodule_instance_args, _module_descriptor, stude
@outer_atomic
def delete_problem_module_state(xmodule_instance_args, _module_descriptor, student_module):
def delete_problem_module_state(xmodule_instance_args, _module_descriptor, student_module, _task_input):
"""
Delete the StudentModule entry.
......
......@@ -140,35 +140,27 @@ class TestRescoringTask(TestIntegrationTask):
expected_subsection_grade,
)
def submit_rescore_all_student_answers(self, instructor, problem_url_name):
def submit_rescore_all_student_answers(self, instructor, problem_url_name, only_if_higher=False):
"""Submits the particular problem for rescoring"""
return submit_rescore_problem_for_all_students(self.create_task_request(instructor),
InstructorTaskModuleTestCase.problem_location(problem_url_name))
return submit_rescore_problem_for_all_students(
self.create_task_request(instructor),
InstructorTaskModuleTestCase.problem_location(problem_url_name),
only_if_higher,
)
def submit_rescore_one_student_answer(self, instructor, problem_url_name, student):
def submit_rescore_one_student_answer(self, instructor, problem_url_name, student, only_if_higher=False):
"""Submits the particular problem for rescoring for a particular student"""
return submit_rescore_problem_for_student(self.create_task_request(instructor),
InstructorTaskModuleTestCase.problem_location(problem_url_name),
student)
RescoreTestData = namedtuple('RescoreTestData', 'edit, new_expected_scores, new_expected_max')
return submit_rescore_problem_for_student(
self.create_task_request(instructor),
InstructorTaskModuleTestCase.problem_location(problem_url_name),
student,
only_if_higher,
)
@ddt.data(
RescoreTestData(edit=dict(correct_answer=OPTION_2), new_expected_scores=(0, 1, 1, 2), new_expected_max=2),
RescoreTestData(edit=dict(num_inputs=2), new_expected_scores=(2, 1, 1, 0), new_expected_max=4),
RescoreTestData(edit=dict(num_inputs=4), new_expected_scores=(2, 1, 1, 0), new_expected_max=8),
RescoreTestData(edit=dict(num_responses=4), new_expected_scores=(2, 1, 1, 0), new_expected_max=4),
RescoreTestData(edit=dict(num_inputs=2, num_responses=4), new_expected_scores=(2, 1, 1, 0), new_expected_max=8),
)
@ddt.unpack
def test_rescoring_option_problem(self, problem_edit, new_expected_scores, new_expected_max):
def verify_rescore_results(self, problem_edit, new_expected_scores, new_expected_max, rescore_if_higher):
"""
Run rescore scenario on option problem.
Verify rescoring updates grade after content change.
Original problem definition has:
num_inputs = 1
num_responses = 2
correct_answer = OPTION_1
Common helper to verify the results of rescoring for a single
student and all students are as expected.
"""
# get descriptor:
problem_url_name = 'H1P1'
......@@ -196,16 +188,50 @@ class TestRescoringTask(TestIntegrationTask):
self.check_state(self.user1, descriptor, expected_original_scores[0], expected_original_max)
# rescore the problem for only one student -- only that student's grade should change:
self.submit_rescore_one_student_answer('instructor', problem_url_name, self.user1)
self.submit_rescore_one_student_answer('instructor', problem_url_name, self.user1, rescore_if_higher)
self.check_state(self.user1, descriptor, new_expected_scores[0], new_expected_max)
for i, user in enumerate(self.users[1:], start=1): # everyone other than user1
self.check_state(user, descriptor, expected_original_scores[i], expected_original_max)
# rescore the problem for all students
self.submit_rescore_all_student_answers('instructor', problem_url_name)
self.submit_rescore_all_student_answers('instructor', problem_url_name, rescore_if_higher)
for i, user in enumerate(self.users):
self.check_state(user, descriptor, new_expected_scores[i], new_expected_max)
RescoreTestData = namedtuple('RescoreTestData', 'edit, new_expected_scores, new_expected_max')
@ddt.data(
RescoreTestData(edit=dict(correct_answer=OPTION_2), new_expected_scores=(0, 1, 1, 2), new_expected_max=2),
RescoreTestData(edit=dict(num_inputs=2), new_expected_scores=(2, 1, 1, 0), new_expected_max=4),
RescoreTestData(edit=dict(num_inputs=4), new_expected_scores=(2, 1, 1, 0), new_expected_max=8),
RescoreTestData(edit=dict(num_responses=4), new_expected_scores=(2, 1, 1, 0), new_expected_max=4),
RescoreTestData(edit=dict(num_inputs=2, num_responses=4), new_expected_scores=(2, 1, 1, 0), new_expected_max=8),
)
@ddt.unpack
def test_rescoring_option_problem(self, problem_edit, new_expected_scores, new_expected_max):
"""
Run rescore scenario on option problem.
Verify rescoring updates grade after content change.
Original problem definition has:
num_inputs = 1
num_responses = 2
correct_answer = OPTION_1
"""
self.verify_rescore_results(
problem_edit, new_expected_scores, new_expected_max, rescore_if_higher=False,
)
@ddt.data(
RescoreTestData(edit=dict(), new_expected_scores=(2, 1, 1, 0), new_expected_max=2),
RescoreTestData(edit=dict(correct_answer=OPTION_2), new_expected_scores=(2, 1, 1, 2), new_expected_max=2),
RescoreTestData(edit=dict(num_inputs=2), new_expected_scores=(2, 1, 1, 0), new_expected_max=2),
)
@ddt.unpack
def test_rescoring_if_higher(self, problem_edit, new_expected_scores, new_expected_max):
self.verify_rescore_results(
problem_edit, new_expected_scores, new_expected_max, rescore_if_higher=True,
)
def test_rescoring_failure(self):
"""Simulate a failure in rescoring a problem"""
problem_url_name = 'H1P1'
......
......@@ -52,10 +52,10 @@ class TestInstructorTasks(InstructorTaskModuleTestCase):
self.instructor = self.create_instructor('instructor')
self.location = self.problem_location(PROBLEM_URL_NAME)
def _create_input_entry(self, student_ident=None, use_problem_url=True, course_id=None):
def _create_input_entry(self, student_ident=None, use_problem_url=True, course_id=None, only_if_higher=False):
"""Creates a InstructorTask entry for testing."""
task_id = str(uuid4())
task_input = {}
task_input = {'only_if_higher': only_if_higher}
if use_problem_url:
task_input['problem_url'] = self.location
if student_ident is not None:
......
......@@ -39,18 +39,25 @@
this.$btn_reset_attempts_single = findAndAssert(this.$section, "input[name='reset-attempts-single']");
this.$btn_delete_state_single = this.$section.find("input[name='delete-state-single']");
this.$btn_rescore_problem_single = this.$section.find("input[name='rescore-problem-single']");
this.$btn_rescore_problem_if_higher_single = this.$section.find(
"input[name='rescore-problem-if-higher-single']"
);
this.$btn_task_history_single = this.$section.find("input[name='task-history-single']");
this.$table_task_history_single = this.$section.find('.task-history-single-table');
this.$field_exam_grade = this.$section.find("input[name='entrance-exam-student-select-grade']");
this.$btn_reset_entrance_exam_attempts = this.$section.find("input[name='reset-entrance-exam-attempts']");
this.$btn_delete_entrance_exam_state = this.$section.find("input[name='delete-entrance-exam-state']");
this.$btn_rescore_entrance_exam = this.$section.find("input[name='rescore-entrance-exam']");
this.$btn_rescore_entrance_exam_if_higher = this.$section.find(
"input[name='rescore-entrance-exam-if-higher']"
);
this.$btn_skip_entrance_exam = this.$section.find("input[name='skip-entrance-exam']");
this.$btn_entrance_exam_task_history = this.$section.find("input[name='entrance-exam-task-history']");
this.$table_entrance_exam_task_history = this.$section.find('.entrance-exam-task-history-table');
this.$field_problem_select_all = this.$section.find("input[name='problem-select-all']");
this.$btn_reset_attempts_all = this.$section.find("input[name='reset-attempts-all']");
this.$btn_rescore_problem_all = this.$section.find("input[name='rescore-problem-all']");
this.$btn_rescore_problem_if_higher_all = this.$section.find("input[name='rescore-problem-all-if-higher']");
this.$btn_task_history_all = this.$section.find("input[name='task-history-all']");
this.$table_task_history_all = this.$section.find('.task-history-all-table');
this.instructor_tasks = new (PendingInstructorTasks())(this.$section);
......@@ -176,46 +183,10 @@
}
});
this.$btn_rescore_problem_single.click(function() {
var errorMessage, fullErrorMessage, fullSuccessMessage,
problemToReset, sendData, successMessage, uniqStudentIdentifier;
uniqStudentIdentifier = studentadmin.$field_student_select_grade.val();
problemToReset = studentadmin.$field_problem_select_single.val();
if (!uniqStudentIdentifier) {
return studentadmin.$request_err_grade.text(
gettext('Please enter a student email address or username.')
);
}
if (!problemToReset) {
return studentadmin.$request_err_grade.text(
gettext('Please enter a problem location.')
);
}
sendData = {
unique_student_identifier: uniqStudentIdentifier,
problem_to_reset: problemToReset
};
successMessage = gettext("Started rescore problem task for problem '<%- problem_id %>' and student '<%- student_id %>'. Click the 'Show Background Task History for Student' button to see the status of the task."); // eslint-disable-line max-len
fullSuccessMessage = _.template(successMessage)({
student_id: uniqStudentIdentifier,
problem_id: problemToReset
});
errorMessage = gettext("Error starting a task to rescore problem '<%- problem_id %>' for student '<%- student_id %>'. Make sure that the the problem and student identifiers are complete and correct."); // eslint-disable-line max-len
fullErrorMessage = _.template(errorMessage)({
student_id: uniqStudentIdentifier,
problem_id: problemToReset
});
return $.ajax({
type: 'POST',
dataType: 'json',
url: studentadmin.$btn_rescore_problem_single.data('endpoint'),
data: sendData,
success: studentadmin.clear_errors_then(function() {
return alert(fullSuccessMessage); // eslint-disable-line no-alert
}),
error: statusAjaxError(function() {
return studentadmin.$request_err_grade.text(fullErrorMessage);
})
});
return studentadmin.rescore_problem_single(false);
});
this.$btn_rescore_problem_if_higher_single.click(function() {
return studentadmin.rescore_problem_single(true);
});
this.$btn_task_history_single.click(function() {
var errorMessage, fullErrorMessage, problemToReset, sendData, uniqStudentIdentifier;
......@@ -289,38 +260,10 @@
});
});
this.$btn_rescore_entrance_exam.click(function() {
var sendData, uniqStudentIdentifier;
uniqStudentIdentifier = studentadmin.$field_exam_grade.val();
if (!uniqStudentIdentifier) {
return studentadmin.$request_err_ee.text(gettext(
'Please enter a student email address or username.')
);
}
sendData = {
unique_student_identifier: uniqStudentIdentifier
};
return $.ajax({
type: 'POST',
dataType: 'json',
url: studentadmin.$btn_rescore_entrance_exam.data('endpoint'),
data: sendData,
success: studentadmin.clear_errors_then(function() {
var fullSuccessMessage, successMessage;
successMessage = 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."); // eslint-disable-line max-len
fullSuccessMessage = interpolate_text(successMessage, {
student_id: uniqStudentIdentifier
});
return alert(fullSuccessMessage); // eslint-disable-line no-alert
}),
error: statusAjaxError(function() {
var errorMessage, fullErrorMessage;
errorMessage = 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."); // eslint-disable-line max-len
fullErrorMessage = interpolate_text(errorMessage, {
student_id: uniqStudentIdentifier
});
return studentadmin.$request_err_ee.text(fullErrorMessage);
})
});
return studentadmin.rescore_entrance_exam_all(false);
});
this.$btn_rescore_entrance_exam_if_higher.click(function() {
return studentadmin.rescore_entrance_exam_all(true);
});
this.$btn_skip_entrance_exam.click(function() {
var confirmMessage, fullConfirmMessage, sendData, uniqStudentIdentifier;
......@@ -435,7 +378,7 @@
all_students: true,
problem_to_reset: problemToReset
};
successMessage = gettext("Successfully started task to reset attempts for problem '<%- problem_id %>'. Click the 'Show Background Task History for Problem' button to see the status of the task."); // eslint-disable-line max-len
successMessage = gettext("Successfully started task to reset attempts for problem '<%- problem_id %>'. Click the 'Show Task Status' button to see the status of the task."); // eslint-disable-line max-len
fullSuccessMessage = _.template(successMessage)({
problem_id: problemToReset
});
......@@ -460,46 +403,10 @@
}
});
this.$btn_rescore_problem_all.click(function() {
var confirmMessage, errorMessage, fullConfirmMessage,
fullErrorMessage, fullSuccessMessage, problemToReset, sendData, successMessage;
problemToReset = studentadmin.$field_problem_select_all.val();
if (!problemToReset) {
return studentadmin.$request_response_error_all.text(
gettext('Please enter a problem location.')
);
}
confirmMessage = gettext("Rescore problem '<%- problem_id %>' for all students?");
fullConfirmMessage = _.template(confirmMessage)({
problem_id: problemToReset
});
if (window.confirm(fullConfirmMessage)) { // eslint-disable-line no-alert
sendData = {
all_students: true,
problem_to_reset: problemToReset
};
successMessage = gettext("Successfully started task to rescore problem '<%- problem_id %>' for all students. Click the 'Show Background Task History for Problem' button to see the status of the task."); // eslint-disable-line max-len
fullSuccessMessage = _.template(successMessage)({
problem_id: problemToReset
});
errorMessage = gettext("Error starting a task to rescore problem '<%- problem_id %>'. Make sure that the problem identifier is complete and correct."); // eslint-disable-line max-len
fullErrorMessage = _.template(errorMessage)({
problem_id: problemToReset
});
return $.ajax({
type: 'POST',
dataType: 'json',
url: studentadmin.$btn_rescore_problem_all.data('endpoint'),
data: sendData,
success: studentadmin.clear_errors_then(function() {
return alert(fullSuccessMessage); // eslint-disable-line no-alert
}),
error: statusAjaxError(function() {
return studentadmin.$request_response_error_all.text(fullErrorMessage);
})
});
} else {
return studentadmin.clear_errors();
}
return studentadmin.rescore_problem_all(false);
});
this.$btn_rescore_problem_if_higher_all.click(function() {
return studentadmin.rescore_problem_all(true);
});
this.$btn_task_history_all.click(function() {
var sendData;
......@@ -528,6 +435,133 @@
});
}
StudentAdmin.prototype.rescore_problem_single = function(onlyIfHigher) {
var errorMessage, fullErrorMessage, fullSuccessMessage,
problemToReset, sendData, successMessage, uniqStudentIdentifier,
that = this;
uniqStudentIdentifier = this.$field_student_select_grade.val();
problemToReset = this.$field_problem_select_single.val();
if (!uniqStudentIdentifier) {
return this.$request_err_grade.text(
gettext('Please enter a student email address or username.')
);
}
if (!problemToReset) {
return this.$request_err_grade.text(
gettext('Please enter a problem location.')
);
}
sendData = {
unique_student_identifier: uniqStudentIdentifier,
problem_to_reset: problemToReset,
only_if_higher: onlyIfHigher
};
successMessage = gettext("Started rescore problem task for problem '<%- problem_id %>' and student '<%- student_id %>'. Click the 'Show Task Status' button to see the status of the task."); // eslint-disable-line max-len
fullSuccessMessage = _.template(successMessage)({
student_id: uniqStudentIdentifier,
problem_id: problemToReset
});
errorMessage = gettext("Error starting a task to rescore problem '<%- problem_id %>' for student '<%- student_id %>'. Make sure that the the problem and student identifiers are complete and correct."); // eslint-disable-line max-len
fullErrorMessage = _.template(errorMessage)({
student_id: uniqStudentIdentifier,
problem_id: problemToReset
});
return $.ajax({
type: 'POST',
dataType: 'json',
url: this.$btn_rescore_problem_single.data('endpoint'),
data: sendData,
success: this.clear_errors_then(function() {
return alert(fullSuccessMessage); // eslint-disable-line no-alert
}),
error: statusAjaxError(function() {
return that.$request_err_grade.text(fullErrorMessage);
})
});
};
StudentAdmin.prototype.rescore_entrance_exam_all = function(onlyIfHigher) {
var sendData, uniqStudentIdentifier,
that = this;
uniqStudentIdentifier = this.$field_exam_grade.val();
if (!uniqStudentIdentifier) {
return this.$request_err_ee.text(gettext(
'Please enter a student email address or username.')
);
}
sendData = {
unique_student_identifier: uniqStudentIdentifier,
only_if_higher: onlyIfHigher
};
return $.ajax({
type: 'POST',
dataType: 'json',
url: this.$btn_rescore_entrance_exam.data('endpoint'),
data: sendData,
success: this.clear_errors_then(function() {
var fullSuccessMessage, successMessage;
successMessage = gettext("Started entrance exam rescore task for student '{student_id}'. Click the 'Show Task Status' button to see the status of the task."); // eslint-disable-line max-len
fullSuccessMessage = interpolate_text(successMessage, {
student_id: uniqStudentIdentifier
});
return alert(fullSuccessMessage); // eslint-disable-line no-alert
}),
error: statusAjaxError(function() {
var errorMessage, fullErrorMessage;
errorMessage = 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."); // eslint-disable-line max-len
fullErrorMessage = interpolate_text(errorMessage, {
student_id: uniqStudentIdentifier
});
return that.$request_err_ee.text(fullErrorMessage);
})
});
};
StudentAdmin.prototype.rescore_problem_all = function(onlyIfHigher) {
var confirmMessage, errorMessage, fullConfirmMessage,
fullErrorMessage, fullSuccessMessage, problemToReset, sendData, successMessage,
that = this;
problemToReset = this.$field_problem_select_all.val();
if (!problemToReset) {
return this.$request_response_error_all.text(
gettext('Please enter a problem location.')
);
}
confirmMessage = gettext("Rescore problem '<%- problem_id %>' for all students?");
fullConfirmMessage = _.template(confirmMessage)({
problem_id: problemToReset
});
if (window.confirm(fullConfirmMessage)) { // eslint-disable-line no-alert
sendData = {
all_students: true,
problem_to_reset: problemToReset,
only_if_higher: onlyIfHigher
};
successMessage = gettext("Successfully started task to rescore problem '<%- problem_id %>' for all students. Click the 'Show Task Status' button to see the status of the task."); // eslint-disable-line max-len
fullSuccessMessage = _.template(successMessage)({
problem_id: problemToReset
});
errorMessage = gettext("Error starting a task to rescore problem '<%- problem_id %>'. Make sure that the problem identifier is complete and correct."); // eslint-disable-line max-len
fullErrorMessage = _.template(errorMessage)({
problem_id: problemToReset
});
return $.ajax({
type: 'POST',
dataType: 'json',
url: this.$btn_rescore_problem_all.data('endpoint'),
data: sendData,
success: this.clear_errors_then(function() {
return alert(fullSuccessMessage); // eslint-disable-line no-alert
}),
error: statusAjaxError(function() {
return that.$request_response_error_all.text(fullErrorMessage);
})
});
} else {
return this.clear_errors();
}
};
StudentAdmin.prototype.clear_errors_then = function(cb) {
this.$request_err.empty();
this.$request_err_grade.empty();
......
......@@ -84,7 +84,7 @@ define(['jquery', 'js/instructor_dashboard/student_admin', 'edx-ui-toolkit/js/ut
it('initiates rescoring of the entrance exam when the button is clicked', function() {
var successMessage = 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."); // eslint-disable-line max-len
" Click the 'Show Task Status' button to see the status of the task."); // eslint-disable-line max-len
var fullSuccessMessage = interpolate_text(successMessage, {
student_id: uniqStudentIdentifier
});
......@@ -94,7 +94,8 @@ define(['jquery', 'js/instructor_dashboard/student_admin', 'edx-ui-toolkit/js/ut
var requests = AjaxHelpers.requests(this);
// Verify that the client contacts the server to start instructor task
var params = $.param({
unique_student_identifier: uniqStudentIdentifier
unique_student_identifier: uniqStudentIdentifier,
only_if_higher: false
});
studentadmin.$btn_rescore_entrance_exam.click();
......@@ -121,7 +122,8 @@ define(['jquery', 'js/instructor_dashboard/student_admin', 'edx-ui-toolkit/js/ut
var requests = AjaxHelpers.requests(this);
// Verify that the client contacts the server to start instructor task
var params = $.param({
unique_student_identifier: uniqStudentIdentifier
unique_student_identifier: uniqStudentIdentifier,
only_if_higher: false
});
var errorMessage = gettext(
"Error starting a task to rescore entrance exam for student '{student_id}'." +
......
......@@ -11,51 +11,52 @@ define([
describe('StaffDebugActions', function() {
var location = 'i4x://edX/Open_DemoX/edx_demo_course/problem/test_loc';
var locationName = 'test_loc';
var fixture_id = 'sd_fu_' + locationName;
var fixture = $('<input>', {id: fixture_id, placeholder: 'userman'});
var fixtureID = 'sd_fu_' + locationName;
var $fixture = $('<input>', {id: fixtureID, placeholder: 'userman'});
var escapableLocationName = 'test\.\*\+\?\^\:\$\{\}\(\)\|\]\[loc';
var escapableFixture_id = 'sd_fu_' + escapableLocationName;
var escapableFixture = $('<input>', {id: escapableFixture_id, placeholder: 'userman'});
var escapableFixtureID = 'sd_fu_' + escapableLocationName;
var $escapableFixture = $('<input>', {id: escapableFixtureID, placeholder: 'userman'});
var esclocationName = 'P2:problem_1';
var escapableId = 'result_' + esclocationName;
var escapableResultArea = $('<div>', {id: escapableId});
describe('get_url ', function() {
describe('getURL ', function() {
it('defines url to courseware ajax entry point', function() {
spyOn(StaffDebug, 'get_current_url')
spyOn(StaffDebug, 'getCurrentUrl')
.and.returnValue('/courses/edX/Open_DemoX/edx_demo_course/courseware/stuff');
expect(StaffDebug.get_url('rescore_problem'))
expect(StaffDebug.getURL('rescore_problem'))
.toBe('/courses/edX/Open_DemoX/edx_demo_course/instructor/api/rescore_problem');
});
});
describe('sanitize_string', function() {
describe('sanitizeString', function() {
it('escapes escapable characters in a string', function() {
expect(StaffDebug.sanitized_string('.*+?^:${}()|][')).toBe('\\.\\*\\+\\?\\^\\:\\$\\{\\}\\(\\)\\|\\]\\[');
expect(StaffDebug.sanitizeString('.*+?^:${}()|]['))
.toBe('\\.\\*\\+\\?\\^\\:\\$\\{\\}\\(\\)\\|\\]\\[');
});
});
describe('get_user', function() {
describe('getUser', function() {
it('gets the placeholder username if input field is empty', function() {
$('body').append(fixture);
expect(StaffDebug.get_user(locationName)).toBe('userman');
$('#' + fixture_id).remove();
$('body').append($fixture);
expect(StaffDebug.getUser(locationName)).toBe('userman');
$('#' + fixtureID).remove();
});
it('gets a filled in name if there is one', function() {
$('body').append(fixture);
$('#' + fixture_id).val('notuserman');
expect(StaffDebug.get_user(locationName)).toBe('notuserman');
$('body').append($fixture);
$('#' + fixtureID).val('notuserman');
expect(StaffDebug.getUser(locationName)).toBe('notuserman');
$('#' + fixture_id).val('');
$('#' + fixture_id).remove();
$('#' + fixtureID).val('');
$('#' + fixtureID).remove();
});
it('gets the placeholder name if the id has escapable characters', function() {
$('body').append(escapableFixture);
expect(StaffDebug.get_user('test.*+?^:${}()|][loc')).toBe('userman');
$('body').append($escapableFixture);
expect(StaffDebug.getUser('test.*+?^:${}()|][loc')).toBe('userman');
$("input[id^='sd_fu_']").remove();
});
});
describe('do_idash_action success', function() {
describe('doInstructorDashAction success', function() {
it('adds a success message to the results element after using an action', function() {
$('body').append(escapableResultArea);
var requests = AjaxHelpers.requests(this);
......@@ -63,82 +64,105 @@ define([
locationName: esclocationName,
success_msg: 'Successfully reset the attempts for user userman'
};
StaffDebug.do_idash_action(action);
StaffDebug.doInstructorDashAction(action);
AjaxHelpers.respondWithJson(requests, action);
expect($('#idash_msg').text()).toBe('Successfully reset the attempts for user userman');
$('#result_' + locationName).remove();
});
});
describe('do_idash_action error', function() {
describe('doInstructorDashAction error', function() {
it('adds a failure message to the results element after using an action', function() {
$('body').append(escapableResultArea);
var requests = AjaxHelpers.requests(this);
var action = {
locationName: esclocationName,
error_msg: 'Failed to reset attempts.'
error_msg: 'Failed to reset attempts for user.'
};
StaffDebug.do_idash_action(action);
StaffDebug.doInstructorDashAction(action);
AjaxHelpers.respondWithError(requests);
expect($('#idash_msg').text()).toBe('Failed to reset attempts. ');
expect($('#idash_msg').text()).toBe('Failed to reset attempts for user. ');
$('#result_' + locationName).remove();
});
});
describe('reset', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append(fixture);
$('body').append($fixture);
spyOn($, 'ajax');
StaffDebug.reset(locationName, location);
expect($.ajax.calls.mostRecent().args[0].type).toEqual('POST');
expect($.ajax.calls.mostRecent().args[0].data).toEqual({
'problem_to_reset': location,
'unique_student_identifier': 'userman',
'delete_module': false
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: false,
only_if_higher: undefined
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/reset_student_attempts'
);
$('#' + fixture_id).remove();
$('#' + fixtureID).remove();
});
});
describe('sdelete', function() {
describe('deleteStudentState', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append(fixture);
$('body').append($fixture);
spyOn($, 'ajax');
StaffDebug.sdelete(locationName, location);
StaffDebug.deleteStudentState(locationName, location);
expect($.ajax.calls.mostRecent().args[0].type).toEqual('POST');
expect($.ajax.calls.mostRecent().args[0].data).toEqual({
'problem_to_reset': location,
'unique_student_identifier': 'userman',
'delete_module': true
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: true,
only_if_higher: undefined
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/reset_student_attempts'
);
$('#' + fixture_id).remove();
$('#' + fixtureID).remove();
});
});
describe('rescore', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append(fixture);
$('body').append($fixture);
spyOn($, 'ajax');
StaffDebug.rescore(locationName, location);
expect($.ajax.calls.mostRecent().args[0].type).toEqual('POST');
expect($.ajax.calls.mostRecent().args[0].data).toEqual({
'problem_to_reset': location,
'unique_student_identifier': 'userman',
'delete_module': false
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: undefined,
only_if_higher: false
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/rescore_problem'
);
$('#' + fixture_id).remove();
$('#' + fixtureID).remove();
});
});
describe('rescoreIfHigher', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append($fixture);
spyOn($, 'ajax');
StaffDebug.rescoreIfHigher(locationName, location);
expect($.ajax.calls.mostRecent().args[0].type).toEqual('POST');
expect($.ajax.calls.mostRecent().args[0].data).toEqual({
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: undefined,
only_if_higher: true
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/rescore_problem'
);
$('#' + fixtureID).remove();
});
});
});
......
// Build StaffDebug object
var StaffDebug = (function() {
get_current_url = function() {
return window.location.pathname;
};
get_url = function(action) {
var pathname = this.get_current_url();
var url = pathname.substr(0, pathname.indexOf('/courseware')) + '/instructor/api/' + action;
return url;
/* global getCurrentUrl:true */
var getURL = function(action) {
var pathname = this.getCurrentUrl();
return pathname.substr(0, pathname.indexOf('/courseware')) + '/instructor/api/' + action;
};
sanitized_string = function(string) {
var sanitizeString = function(string) {
return string.replace(/[.*+?^:${}()|[\]\\]/g, '\\$&');
};
get_user = function(locname) {
locname = sanitized_string(locname);
var uname = $('#sd_fu_' + locname).val();
var getUser = function(locationName) {
var sanitizedLocationName = sanitizeString(locationName);
var uname = $('#sd_fu_' + sanitizedLocationName).val();
if (uname === '') {
uname = $('#sd_fu_' + locname).attr('placeholder');
uname = $('#sd_fu_' + sanitizedLocationName).attr('placeholder');
}
return uname;
};
do_idash_action = function(action) {
var doInstructorDashAction = function(action) {
var pdata = {
'problem_to_reset': action.location,
'unique_student_identifier': get_user(action.locationName),
'delete_module': action.delete_module
problem_to_reset: action.location,
unique_student_identifier: getUser(action.locationName),
delete_module: action.delete_module,
only_if_higher: action.only_if_higher
};
$.ajax({
type: 'POST',
url: get_url(action.method),
url: getURL(action.method),
data: pdata,
success: function(data) {
var text = _.template(action.success_msg, {interpolate: /\{(.+?)\}/g})(
......@@ -40,72 +37,90 @@ var StaffDebug = (function() {
var html = _.template('<p id="idash_msg" class="success">{text}</p>', {interpolate: /\{(.+?)\}/g})(
{text: text}
);
$('#result_' + sanitized_string(action.locationName)).html(html);
$('#result_' + sanitizeString(action.locationName)).html(html);
},
error: function(request, status, error) {
var response_json;
var responseJSON;
try {
response_json = $.parseJSON(request.responseText);
responseJSON = $.parseJSON(request.responseText);
} catch (e) {
response_json = {error: gettext('Unknown Error Occurred.')};
responseJSON = {error: gettext('Unknown Error Occurred.')};
}
var text = _.template('{error_msg} {error}', {interpolate: /\{(.+?)\}/g})(
{
error_msg: action.error_msg,
error: response_json.error
error: responseJSON.error
}
);
var html = _.template('<p id="idash_msg" class="error">{text}</p>', {interpolate: /\{(.+?)\}/g})(
{text: text}
);
$('#result_' + sanitized_string(action.locationName)).html(html);
$('#result_' + sanitizeString(action.locationName)).html(html);
},
dataType: 'json'
});
};
reset = function(locname, location) {
this.do_idash_action({
var reset = function(locname, location) {
this.doInstructorDashAction({
locationName: locname,
location: location,
method: 'reset_student_attempts',
success_msg: gettext('Successfully reset the attempts for user {user}'),
error_msg: gettext('Failed to reset attempts.'),
error_msg: gettext('Failed to reset attempts for user.'),
delete_module: false
});
};
sdelete = function(locname, location) {
this.do_idash_action({
var deleteStudentState = function(locname, location) {
this.doInstructorDashAction({
locationName: locname,
location: location,
method: 'reset_student_attempts',
success_msg: gettext('Successfully deleted student state for user {user}'),
error_msg: gettext('Failed to delete student state.'),
error_msg: gettext('Failed to delete student state for user.'),
delete_module: true
});
};
rescore = function(locname, location) {
this.do_idash_action({
var rescore = function(locname, location) {
this.doInstructorDashAction({
locationName: locname,
location: location,
method: 'rescore_problem',
success_msg: gettext('Successfully rescored problem for user {user}'),
error_msg: gettext('Failed to rescore problem.'),
delete_module: false
error_msg: gettext('Failed to rescore problem for user.'),
only_if_higher: false
});
};
var rescoreIfHigher = function(locname, location) {
this.doInstructorDashAction({
locationName: locname,
location: location,
method: 'rescore_problem',
success_msg: gettext('Successfully rescored problem to improve score for user {user}'),
error_msg: gettext('Failed to rescore problem to improve score for user.'),
only_if_higher: true
});
};
getCurrentUrl = function() {
return window.location.pathname;
};
return {
reset: reset,
sdelete: sdelete,
deleteStudentState: deleteStudentState,
rescore: rescore,
do_idash_action: do_idash_action,
get_current_url: get_current_url,
get_url: get_url,
get_user: get_user,
sanitized_string: sanitized_string
rescoreIfHigher: rescoreIfHigher,
// export for testing
doInstructorDashAction: doInstructorDashAction,
getCurrentUrl: getCurrentUrl,
getURL: getURL,
getUser: getUser,
sanitizeString: sanitizeString
}; })();
// Register click handlers
......@@ -116,11 +131,15 @@ $(document).ready(function() {
return false;
});
$courseContent.on('click', '.staff-debug-sdelete', function() {
StaffDebug.sdelete($(this).parent().data('location-name'), $(this).parent().data('location'));
StaffDebug.deleteStudentState($(this).parent().data('location-name'), $(this).parent().data('location'));
return false;
});
$courseContent.on('click', '.staff-debug-rescore', function() {
StaffDebug.rescore($(this).parent().data('location-name'), $(this).parent().data('location'));
return false;
});
$courseContent.on('click', '.staff-debug-rescore-if-higher', function() {
StaffDebug.rescoreIfHigher($(this).parent().data('location-name'), $(this).parent().data('location'));
return false;
});
});
......@@ -1283,7 +1283,8 @@
// view - student admin
// --------------------
.instructor-dashboard-wrapper-2 section.idash-section#student_admin > {
.instructor-dashboard-wrapper-2 section.idash-section#student_admin {
.action-type-container{
margin-bottom: $baseline * 2;
}
......@@ -1295,12 +1296,23 @@
.task-history-all-table {
margin-top: 1em;
}
.task-history-single-table {
margin-top: 1em;
}
.running-tasks-table {
margin-top: 1em;
}
input[type="text"] {
width: 651px;
}
.location-example {
font-style: italic;
font-size: 0.9em;
}
}
// view - data download
......
<%page args="section_data" expression_filter="h"/>
<%! from django.utils.translation import ugettext as _ %>
<div>
%if section_data['is_small_course']:
## Show the gradebook for small courses
<h3 class="hd hd-3">${_("Student Gradebook")}</h3>
<p>
${_("Click here to view the gradebook for enrolled students. This feature is only visible to courses with a small number of total enrolled students.")}
</p>
<br>
<p>
<a href="${ section_data['spoc_gradebook_url'] }" class="gradebook-link"> ${_("View Gradebook")} </a>
</p>
<hr>
%endif
%if section_data['access']['instructor']:
<div class="action-type-container">
%if section_data['is_small_course']:
<br><br>
<h4 class="hd hd-4">${_("View gradebook for enrolled learners")}</h4>
<br>
<label for="gradebook-link">${_("Note: This feature is available only to courses with a small number of enrolled learners.")}</label>
<br><br>
<span name="gradebook-link"><a href="${ section_data['spoc_gradebook_url'] }" class="gradebook-link"> ${_("View Gradebook")} </a></span>
<br><br>
<hr>
%endif
</div>
<div class="student-specific-container action-type-container">
<h3 class="hd hd-3">${_("Student-specific grade inspection")}</h3>
<div class="request-response-error"></div>
<br />
<label>
${_("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")}" >
</label>
<br>
<div class="progress-link-wrapper">
<p>
${_("Click this link to view the student's progress page:")}
<a href="" class="progress-link" data-endpoint="${ section_data['get_student_progress_url_url'] }"> ${_("Student Progress Page")} </a>
</p>
</div>
<h4 class="hd hd-4">${_("View a specific learner's grades and progress")}</h4>
<div class="request-response-error"></div>
<label for="student-select-progress">
${_("Learner's {platform_name} email address or username *").format(platform_name=settings.PLATFORM_NAME)}
</label>
<br>
<input type="text" name="student-select-progress" placeholder="${_('Learner email address or username')}" >
<br><br>
<div class="progress-link-wrapper">
<span name="progress-link">
<a href="" class="progress-link" data-endpoint="${ section_data['get_student_progress_url_url'] }">
${_("View Progress Page")}
</a>
</span>
</div>
<hr>
</div>
<div class="student-grade-container action-type-container">
<h3 class="hd hd-3">${_("Student-specific grade adjustment")}</h3>
<div class="request-response-error"></div>
<p>
<label>
${_("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")}">
</label>
</p>
<br>
<label> ${_("Specify a problem in the course here with its complete location:")}
<input type="text" name="problem-select-single" placeholder="${_("Problem location")}">
</label>
## 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/>
<code>block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378</code></p>
<p>
${_("Next, select an action to perform for the given user and problem:")}
</p>
<p>
<input type="button" name="reset-attempts-single" value="${_("Reset Student Attempts")}" data-endpoint="${ section_data['reset_student_attempts_url'] }">
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<input type="button" name="rescore-problem-single" value="${_("Rescore Student Submission")}" data-endpoint="${ section_data['rescore_problem_url'] }">
%endif
</p>
<p>
%if section_data['access']['instructor']:
<label> ${_('You may also delete the entire state of a student for the specified problem:')}
<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
</p>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<p id="task-history-single-help">
${_("Rescoring runs in the background, and status for active tasks will appear in the 'Pending Tasks' table. "
"To see status for all tasks submitted for this problem and student, click on this button:")}
</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>
%endif
<h4 class="hd hd-4">${_("Adjust a learner's grade for a specific problem")}</h4>
<div class="request-response-error"></div>
<label for="student-select-grade">
${_("Learner's {platform_name} email address or username *").format(platform_name=settings.PLATFORM_NAME)}
</label>
<br>
<input type="text" name="student-select-grade" placeholder="${_('Learner email address or username')}">
</label>
<br><br>
<label for="problem-select-single">
${_("Location of problem in course *")}<br>
<span class="location-example">${_("Example")}: block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378</span>
</label>
<br>
<input type="text" name="problem-select-single" placeholder="${_('Problem location')}">
<br><br><br>
<h5 class="hd hd-5">${_("Attempts")}</h5>
<label for="reset-attempts-single">${_("Allow a learner who has used up all attempts to work on the problem again.")}</label>
<br>
<input type="button" name="reset-attempts-single" value="${_('Reset Attempts to Zero')}" data-endpoint="${ section_data['reset_student_attempts_url'] }">
<br><br>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<h5 class="hd hd-5">${_("Rescore")}</h5>
<label for="rescore-actions-single">${_("For the specified problem, rescore the learner's responses. The 'Rescore Only If Score Improves' option updates the learner's score only if it improves in the learner's favor.")}</label>
<br>
<span name="rescore-actions-single">
<input type="button" name="rescore-problem-single" value="${_('Rescore Learner\'s Submission')}" data-endpoint="${ section_data['rescore_problem_url'] }">
<input type="button" name="rescore-problem-if-higher-single" value="${_('Rescore Only If Score Improves')}" data-endpoint="${ section_data['rescore_problem_url'] }">
</span>
%endif
<br><br>
<h5 class="hd hd-5">${_("Problem History")}</h5>
<label for="delete-state-single">${_("For the specified problem, permanently and completely delete the learner's answers and scores from the database.")}</label>
<br>
<input type="button" class="molly-guard" name="delete-state-single" value="${_('Delete Learner\'s State')}" data-endpoint="${ section_data['reset_student_attempts_url'] }">
<br><br>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<h5 class="hd hd-5">${_("Task Status")}</h5>
<label for="task-history-single">${_("Show the status for the rescoring tasks that you submitted for this learner and problem.")}</label>
<br>
<input type="button" name="task-history-single" value="${_('Show Task Status')}" data-endpoint="${ section_data['list_instructor_tasks_url'] }" aria-describedby="task-history-single-help">
<div class="task-history-single-table"></div>
%endif
<hr>
</div>
% if course.entrance_exam_enabled:
<div class="entrance-exam-grade-container action-type-container">
<h3 class="hd hd-3">${_("Entrance Exam Adjustment")}</h3>
<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
<input type="button" name="skip-entrance-exam" value="${_('Let Student Skip Entrance Exam')}" data-endpoint="${ section_data['student_can_skip_entrance_exam_url'] }">
<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 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>
<h4 class="hd hd-4">${_("Adjust a learner's entrance exam results")}</h4>
<div class="request-response-error"></div>
<label for="entrance-exam-student-select-grade">
${_("Learner's {platform_name} email address or username *").format(platform_name=settings.PLATFORM_NAME)}
</label>
<br>
<input type="text" name="entrance-exam-student-select-grade" placeholder="${_('Learner email address or username')}">
<br><br><br>
<h5 class="hd hd-5">${_("Attempts")}</h5>
<label for="reset-entrance-exam-attempts">${_("Allow the learner to take the exam again.")}</label>
<br>
<input type="button" name="reset-entrance-exam-attempts" value="${_('Reset Attempts to Zero')}" data-endpoint="${ section_data['reset_student_attempts_for_entrance_exam_url'] }">
<br><br>
<h5 class="hd hd-5">${_("Allow Skip")}</h5>
<label for="skip-entrance-exam">${_("Waive the requirement for the learner to take the exam.")}</label>
<br>
<input type="button" name="skip-entrance-exam" value="${_('Let Learner Skip Entrance Exam')}" data-endpoint="${ section_data['student_can_skip_entrance_exam_url'] }">
<br><br>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<h5 class="hd hd-5">${_("Rescore")}</h5>
<label for="rescore-actions-entrance-exam">
${_("Rescore any responses that have been submitted. The 'Rescore All Problems Only If Score Improves' option updates the learner's scores only if it improves in the learner's favor.")}
</label>
<br>
<span name="rescore-actions-entrance-exam">
<input type="button" name="rescore-entrance-exam" value="${_('Rescore All Problems')}" data-endpoint="${ section_data['rescore_entrance_exam_url'] }">
<input type="button" name="rescore-entrance-exam-if-higher" value="${_('Rescore All Problems Only If Score Improves')}" data-endpoint="${ section_data['rescore_entrance_exam_url'] }">
</span>
<br><br>
%endif
<h5 class="hd hd-5">${_("Entrance Exam History")}</h5>
<label for="delete-entrance-exam-state">
${_("For the entire entrance exam, permanently and completely delete the learner's answers and scores from the database.")}
</label>
<br>
<input type="button" class="molly-guard" name="delete-entrance-exam-state" value="${_('Delete Learner\'s State')}" data-endpoint="${ section_data['reset_student_attempts_for_entrance_exam_url'] }"></label>
<br><br>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<h5 class="hd hd-5">${_("Task Status")}</h5>
<label for="entrance-exam-task-history">
${_("Show the status for the rescoring tasks that you submitted for this learner and entrance exam.")}
</label>
<br>
<p><input type="button" name="entrance-exam-task-history" value="${_('Show Task Status')}" 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']:
<div class="course-specific-container action-type-container">
<h3 class="hd hd-3">${_('Course-specific grade adjustment')}</h3>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="course-specific-container action-type-container">
<h4 class="hd hd-4">${_("Adjust all enrolled learners' grades for a specific problem")}</h4>
<div class="request-response-error"></div>
<label>
${_("Specify a problem in the course here with its complete location:")}
<input type="text" name="problem-select-all" size="60" placeholder="${_("Problem location")}" aria-describedby="problem-select-all-help">
<label for="problem-select-all">
${_("Location of problem in course *")}<br>
<span class="location-example">${_("Example")}: block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378</span>
</label>
## Translators: A location (string of text) follows this sentence.
<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/>
<code>block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378</code></p>
<p>
${_("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="rescore-problem-all" value="${_("Rescore ALL students' problem submissions")}" data-endpoint="${ section_data['rescore_problem_url'] }">
</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. "
"To see status for all tasks submitted for this problem, click on this button")}:
</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>
</p>
</div>
<br>
<input type="text" name="problem-select-all" placeholder="${_('Problem location')}">
<br><br><br>
<h5 class="hd hd-5">${_("Attempts")}</h5>
<label for="reset-attempts-all">${_("Allows all learners to work on the problem again.")}</label>
<br>
<input type="button" class="molly-guard" name="reset-attempts-all" value="${_('Reset Attempts to Zero')}" data-endpoint="${ section_data['reset_student_attempts_url'] }">
<br><br>
<h5 class="hd hd-5">${_("Rescore")}</h5>
<label for="rescore-actions-all">${_("Rescore submitted responses. The 'Rescore Only If Scores Improve' option updates a learner's score only if it improves in the learner's favor.")}</label>
<br>
<span name="rescore-actions-all">
<input type="button" class="molly-guard" name="rescore-problem-all" value="${_('Rescore All Learners\' Submissions')}" data-endpoint="${ section_data['rescore_problem_url'] }">
<input type="button" class="molly-guard" name="rescore-problem-all-if-higher" value="${_('Rescore Only If Scores Improve')}" data-endpoint="${ section_data['rescore_problem_url'] }">
</span>
<br><br>
<h5 class="hd hd-5">${_("Task Status")}</h5>
<label for="task-history-all">${_("Show the status for the tasks that you submitted for this problem.")}</label>
<br>
<input type="button" name="task-history-all" value="${_('Show Task Status')}" data-endpoint="${ section_data['list_instructor_tasks_url'] }" aria-describedby="task-history-all-help">
<div class="task-history-all-table"></div>
<hr>
</div>
%endif
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="running-tasks-container action-type-container">
<hr>
<h3 class="hd hd-3">${_("Pending Tasks")}</h3>
<div class="running-tasks-container action-type-container">
<h4 class="hd hd-4">${_("Pending Tasks")}</h4>
<div class="running-tasks-section">
<p>${_("The status for any active tasks appears in a table below.")} </p>
<br />
<div class="running-tasks-table" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></div>
<label>${_("The status for any active tasks appears in a table below.")}</label>
<br>
<div class="running-tasks-table" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></div>
</div>
<div class="no-pending-tasks-message"></div>
</div>
</div>
%endif
%endif
......@@ -69,14 +69,16 @@ ${block_content}
<div data-location="${location | h}" data-location-name="${location.name | h}">
[
% if can_reset_attempts:
<button type="button" class="btn-link staff-debug-reset">${_('Reset Student Attempts')}</button>
<button type="button" class="btn-link staff-debug-reset">${_('Reset Learner\'s Attempts to Zero')}</button>
|
% endif
% if has_instructor_access:
<button type="button" class="btn-link staff-debug-sdelete">${_('Delete Student State')}</button>
<button type="button" class="btn-link staff-debug-sdelete">${_('Delete Learner\'s State')}</button>
% if can_rescore_problem:
|
<button type="button" class="btn-link staff-debug-rescore">${_('Rescore Student Submission')}</button>
<button type="button" class="btn-link staff-debug-rescore">${_('Rescore Learner\'s Submission')}</button>
|
<button type="button" class="btn-link staff-debug-rescore-if-higher">${_('Rescore Only If Score Improves')}</button>
% endif
% endif
]
......
......@@ -297,7 +297,7 @@ def set_credit_requirement_status(user, course_key, req_namespace, req_name, sta
try:
send_credit_notifications(user.username, course_key)
except Exception: # pylint: disable=broad-except
log.error("Error sending email")
log.exception("Error sending email")
# pylint: disable=invalid-name
......
"""
Helpers functions for grades and scores.
"""
def compare_scores(earned1, possible1, earned2, possible2):
"""
Returns a tuple of:
1. Whether the 2nd set of scores is higher than the first.
2. Grade percentage of 1st set of scores.
3. Grade percentage of 2nd set of scores.
"""
percentage1 = float(earned1) / float(possible1)
percentage2 = float(earned2) / float(possible2)
is_higher = percentage2 > percentage1
return is_higher, percentage1, percentage2
def is_score_higher(earned1, possible1, earned2, possible2):
"""
Returns whether the 2nd set of scores is higher than the first.
"""
is_higher, _, _ = compare_scores(earned1, possible1, earned2, possible2)
return is_higher
......@@ -204,8 +204,7 @@ class GradePublishTestMixin(object):
'max_score': max_score})
self.scores = []
patcher = mock.patch("courseware.module_render.set_score",
capture_score)
patcher = mock.patch("lms.djangoapps.grades.signals.handlers.set_score", capture_score)
patcher.start()
self.addCleanup(patcher.stop)
......
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