Commit ada4e082 by Nimisha Asthagiri Committed by GitHub

Merge pull request #13721 from edx/beryl/rescore_higher

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