From 41f3bd0375732504153b413cf1fbfd038177fdd2 Mon Sep 17 00:00:00 2001 From: Jeff Ericson Date: Wed, 4 Dec 2013 08:45:35 -0800 Subject: [PATCH] Added quiz-attempts timer to capa_module --- CHANGELOG.rst | 7 + .../contentstore/features/problem-editor.py | 3 +- common/lib/xmodule/xmodule/capa_module.py | 63 ++- .../tests/test_delay_between_attempts.py | 410 ++++++++++++++++++ 4 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 common/lib/xmodule/xmodule/tests/test_delay_between_attempts.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a62b0fb0091e..3a837cf22cf8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -118,6 +118,13 @@ client error are correctly passed through to the client. LMS: Improve performance of page load and thread list load for discussion tab +Studio: Added feature to allow instructors to specify wait time between attempts +of the same quiz. In a problem's settings, instructors can specify how many +seconds student's are locked out of submitting another attempt of the same quiz. +The timer starts as soon as they submit an attempt for grading. Note that this +does not prevent a student from starting to work on another quiz attempt. It only +prevents the students from submitting a bunch of attempts in rapid succession. + LMS: The wiki markup cheatsheet dialog is now accessible to screen readers. (LMS-1303) diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index 2265b5010e4c..a68e6d1f73e3 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -14,7 +14,7 @@ PROBLEM_WEIGHT = "Problem Weight" RANDOMIZATION = 'Randomization' SHOW_ANSWER = "Show Answer" - +TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts" @step('I have created a Blank Common Problem$') def i_created_blank_common_problem(step): @@ -45,6 +45,7 @@ def i_see_advanced_settings_with_values(step): [PROBLEM_WEIGHT, "", False], [RANDOMIZATION, "Never", False], [SHOW_ANSWER, "Finished", False], + [TIMER_BETWEEN_ATTEMPTS, "0", False] ]) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index b78e2a4a5019..f48802a4dc0f 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -141,6 +141,14 @@ class CapaFields(object): student_answers = Dict(help="Dictionary with the current student responses", scope=Scope.user_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state) seed = Integer(help="Random seed for this student", scope=Scope.user_state) + + last_submission_time = Date(help="Last submission time", scope=Scope.user_state) + submission_wait_seconds = Integer( + display_name="Timer Between Attempts", + help="Seconds a student must wait between submissions for a problem with multiple attempts.", + scope=Scope.settings, + default=0) + weight = Float( display_name="Problem Weight", help=("Defines the number of points each problem is worth. " @@ -303,6 +311,12 @@ def set_state_from_lcp(self): self.student_answers = lcp_state['student_answers'] self.seed = lcp_state['seed'] + def set_last_submission_time(self): + """ + Set the module's last submission time (when the problem was checked) + """ + self.last_submission_time = datetime.datetime.now(UTC()) + def get_score(self): """ Access the problem's score @@ -894,7 +908,7 @@ def publish_grade(self): return {'grade': score['score'], 'max_grade': score['total']} - def check_problem(self, data): + def check_problem(self, data, override_time=False): """ Checks whether answers to a problem are correct @@ -909,6 +923,11 @@ def check_problem(self, data): answers = self.make_dict_of_responses(data) event_info['answers'] = convert_files_to_filenames(answers) + # Can override current time + current_time = datetime.datetime.now(UTC()) + if override_time is not False: + current_time = override_time + # Too late. Cannot submit if self.closed(): event_info['failure'] = 'closed' @@ -923,23 +942,31 @@ def check_problem(self, data): # Problem queued. Students must wait a specified waittime before they are allowed to submit if self.lcp.is_queued(): - current_time = datetime.datetime.now(UTC()) prev_submit_time = self.lcp.get_recentmost_queuetime() + waittime_between_requests = self.system.xqueue['waittime'] if (current_time - prev_submit_time).total_seconds() < waittime_between_requests: msg = u'You must wait at least {wait} seconds between submissions'.format( wait=waittime_between_requests) return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback + # Wait time between resets + if self.last_submission_time is not None and self.submission_wait_seconds != 0: + if (current_time - self.last_submission_time).total_seconds() < self.submission_wait_seconds: + seconds_left = int(self.submission_wait_seconds - (current_time - self.last_submission_time).total_seconds()) + msg = u'You must wait at least {w} between submissions. {s} remaining.'.format( + w=self.pretty_print_seconds(self.submission_wait_seconds), s=self.pretty_print_seconds(seconds_left)) + return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback + try: correct_map = self.lcp.grade_answers(answers) self.attempts = self.attempts + 1 self.lcp.done = True self.set_state_from_lcp() + self.set_last_submission_time() except (StudentInputError, ResponseError, LoncapaProblemError) as inst: - log.warning("StudentInputError in capa_module:problem_check", - exc_info=True) + log.warning("StudentInputError in capa_module:problem_check", exc_info=True) # Save the user's state before failing self.set_state_from_lcp() @@ -990,9 +1017,31 @@ def check_problem(self, data): # render problem into HTML html = self.get_problem_html(encapsulate=False) - return {'success': success, - 'contents': html, - } + return {'success': success, 'contents': html} + + def pretty_print_seconds(self, num_seconds): + """ + Returns time formatted nicely. + """ + if(num_seconds < 60): + plural = "s" if num_seconds > 1 else "" + return "%i second%s" % (num_seconds, plural) + elif(num_seconds < 60 * 60): + min_display = int(num_seconds / 60) + sec_display = num_seconds % 60 + plural = "s" if min_display > 1 else "" + if sec_display == 0: + return "%i minute%s" % (min_display, plural) + else: + return "%i min, %i sec" % (min_display, sec_display) + else: + hr_display = int(num_seconds / 3600) + min_display = int((num_seconds % 3600) / 60) + sec_display = num_seconds % 60 + if sec_display == 0: + return "%i hr, %i min" % (hr_display, min_display) + else: + return "%i hr, %i min, %i sec" % (hr_display, min_display, sec_display) def rescore_problem(self): """ diff --git a/common/lib/xmodule/xmodule/tests/test_delay_between_attempts.py b/common/lib/xmodule/xmodule/tests/test_delay_between_attempts.py new file mode 100644 index 000000000000..dadb513ad445 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_delay_between_attempts.py @@ -0,0 +1,410 @@ +""" +Tests the logic of problems with a delay between attempt submissions. + +Note that this test file is based off of test_capa_module.py and as +such, uses the same CapaFactory problem setup to test the functionality +of the check_problem method of a capa module when the "delay between quiz +submissions" setting is set to different values +""" + +import unittest +import textwrap +import datetime + +from mock import Mock + +import xmodule +from xmodule.capa_module import CapaModule +from xmodule.modulestore import Location +from xblock.field_data import DictFieldData +from xblock.fields import ScopeIds + +from . import get_test_system +from pytz import UTC + + +class XModuleQuizAttemptsDelayTest(unittest.TestCase): + ''' + Actual class to test delay between quiz attempts + ''' + + def test_first_submission(self): + # Not attempted yet + num_attempts = 0 + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, last_submission_time=None) + + # Simulate problem is not completed yet + module.done = False + + # Expect that we can submit + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict) + + # Successfully submitted and answered + # Also, the number of attempts should increment by 1 + self.assertEqual(result['success'], 'correct') + self.assertEqual(module.attempts, num_attempts + 1) + + def test_no_wait_time(self): + # Already attempted once (just now) and thus has a submitted time + num_attempts = 1 + last_submitted_time = datetime.datetime.now(UTC) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=0) + + # Simulate problem is not completed yet + module.done = False + + # Expect that we can submit + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict) + + # Successfully submitted and answered + # Also, the number of attempts should increment by 1 + self.assertEqual(result['success'], 'correct') + self.assertEqual(module.attempts, num_attempts + 1) + + def test_submit_quiz_in_rapid_succession(self): + # Already attempted once (just now) and thus has a submitted time + num_attempts = 1 + last_submitted_time = datetime.datetime.now(UTC) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=123) + + # Simulate problem is not completed yet + module.done = False + + # Check the problem + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict) + + # You should get a dialog that tells you to wait + # Also, the number of attempts should not be incremented + self.assertRegexpMatches(result['success'], r"You must wait at least.*") + self.assertEqual(module.attempts, num_attempts) + + def test_submit_quiz_too_soon(self): + # Already attempted once (just now) + num_attempts = 1 + + # Specify two times + last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36) + considered_now = datetime.datetime(2013, 12, 6, 0, 18, 36) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=180) + + # Simulate problem is not completed yet + module.done = False + + # Check the problem + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict, considered_now) + + # You should get a dialog that tells you to wait 2 minutes + # Also, the number of attempts should not be incremented + self.assertRegexpMatches(result['success'], r"You must wait at least 3 minutes between submissions. 2 minutes remaining\..*") + self.assertEqual(module.attempts, num_attempts) + + def test_submit_quiz_1_second_too_soon(self): + # Already attempted once (just now) + num_attempts = 1 + + # Specify two times + last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36) + considered_now = datetime.datetime(2013, 12, 6, 0, 20, 35) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=180) + + # Simulate problem is not completed yet + module.done = False + + # Check the problem + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict, considered_now) + + # You should get a dialog that tells you to wait 2 minutes + # Also, the number of attempts should not be incremented + self.assertRegexpMatches(result['success'], r"You must wait at least 3 minutes between submissions. 1 second remaining\..*") + self.assertEqual(module.attempts, num_attempts) + + def test_submit_quiz_as_soon_as_allowed(self): + # Already attempted once (just now) + num_attempts = 1 + + # Specify two times + last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36) + considered_now = datetime.datetime(2013, 12, 6, 0, 20, 36) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=180) + + # Simulate problem is not completed yet + module.done = False + + # Expect that we can submit + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict, considered_now) + + # Successfully submitted and answered + # Also, the number of attempts should increment by 1 + self.assertEqual(result['success'], 'correct') + self.assertEqual(module.attempts, num_attempts + 1) + + def test_submit_quiz_after_delay_expired(self): + # Already attempted once (just now) + num_attempts = 1 + + # Specify two times + last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36) + considered_now = datetime.datetime(2013, 12, 6, 0, 24, 0) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=180) + + # Simulate problem is not completed yet + module.done = False + + # Expect that we can submit + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict, considered_now) + + # Successfully submitted and answered + # Also, the number of attempts should increment by 1 + self.assertEqual(result['success'], 'correct') + self.assertEqual(module.attempts, num_attempts + 1) + + def test_still_cannot_submit_after_max_attempts(self): + # Already attempted once (just now) and thus has a submitted time + num_attempts = 99 + + # Specify two times + last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36) + considered_now = datetime.datetime(2013, 12, 6, 0, 24, 0) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=180) + + # Simulate problem is not completed yet + module.done = False + + # Expect that we cannot submit + with self.assertRaises(xmodule.exceptions.NotFoundError): + get_request_dict = {CapaFactory.input_key(): '3.14'} + module.check_problem(get_request_dict, considered_now) + + # Expect that number of attempts NOT incremented + self.assertEqual(module.attempts, num_attempts) + + def test_submit_quiz_with_long_delay(self): + # Already attempted once (just now) + num_attempts = 1 + + # Specify two times + last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36) + considered_now = datetime.datetime(2013, 12, 6, 2, 15, 35) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=60 * 60 * 2) + + # Simulate problem is not completed yet + module.done = False + + # Check the problem + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict, considered_now) + + # You should get a dialog that tells you to wait 2 minutes + # Also, the number of attempts should not be incremented + self.assertRegexpMatches(result['success'], r"You must wait at least 2 hr, 0 min between submissions. 2 min, 1 sec remaining\..*") + self.assertEqual(module.attempts, num_attempts) + + def test_submit_quiz_with_involved_pretty_print(self): + # Already attempted once (just now) + num_attempts = 1 + + # Specify two times + last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36) + considered_now = datetime.datetime(2013, 12, 6, 1, 15, 40) + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=60 * 60 * 2 + 63) + + # Simulate problem is not completed yet + module.done = False + + # Check the problem + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict, considered_now) + + # You should get a dialog that tells you to wait 2 minutes + # Also, the number of attempts should not be incremented + self.assertRegexpMatches(result['success'], r"You must wait at least 2 hr, 1 min, 3 sec between submissions. 1 hr, 2 min, 59 sec remaining\..*") + self.assertEqual(module.attempts, num_attempts) + + def test_submit_quiz_with_nonplural_pretty_print(self): + # Already attempted once (just now) + num_attempts = 1 + + # Specify two times + last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36) + considered_now = last_submitted_time + + # Many attempts remaining + module = CapaFactory.create(attempts=num_attempts, max_attempts=99, + last_submission_time=last_submitted_time, submission_wait_seconds=60) + + # Simulate problem is not completed yet + module.done = False + + # Check the problem + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict, considered_now) + + # You should get a dialog that tells you to wait 2 minutes + # Also, the number of attempts should not be incremented + self.assertRegexpMatches(result['success'], r"You must wait at least 1 minute between submissions. 1 minute remaining\..*") + self.assertEqual(module.attempts, num_attempts) + + +class CapaFactory(object): + """ + A helper class to create problem modules with various parameters for testing. + """ + + sample_problem_xml = textwrap.dedent("""\ + + + +

What is pi, to two decimal places?

+
+ + + +
+ """) + + num = 0 + + @classmethod + def next_num(cls): + """ + Return the next cls number + """ + cls.num += 1 + return cls.num + + @classmethod + def input_key(cls, input_num=2): + """ + Return the input key to use when passing GET parameters + """ + return ("input_" + cls.answer_key(input_num)) + + @classmethod + def answer_key(cls, input_num=2): + """ + Return the key stored in the capa problem answer dict + """ + return ( + "%s_%d_1" % ( + "-".join(['i4x', 'edX', 'capa_test', 'problem', 'SampleProblem%d' % cls.num]), + input_num, + ) + ) + + @classmethod + def create(cls, + graceperiod=None, + due=None, + max_attempts=None, + showanswer=None, + rerandomize=None, + force_save_button=None, + attempts=None, + problem_state=None, + correct=False, + done=None, + text_customization=None, + last_submission_time=None, + submission_wait_seconds=None + ): + """ + All parameters are optional, and are added to the created problem if specified. + + Arguments: + graceperiod: + due: + max_attempts: + showanswer: + force_save_button: + rerandomize: all strings, as specified in the policy for the problem + + problem_state: a dict to to be serialized into the instance_state of the + module. + + attempts: also added to instance state. Will be converted to an int. + """ + location = Location(["i4x", "edX", "capa_test", "problem", + "SampleProblem{0}".format(cls.next_num())]) + field_data = {'data': cls.sample_problem_xml} + + if graceperiod is not None: + field_data['graceperiod'] = graceperiod + if due is not None: + field_data['due'] = due + if max_attempts is not None: + field_data['max_attempts'] = max_attempts + if showanswer is not None: + field_data['showanswer'] = showanswer + if force_save_button is not None: + field_data['force_save_button'] = force_save_button + if rerandomize is not None: + field_data['rerandomize'] = rerandomize + if done is not None: + field_data['done'] = done + if text_customization is not None: + field_data['text_customization'] = text_customization + if last_submission_time is not None: + field_data['last_submission_time'] = last_submission_time + if submission_wait_seconds is not None: + field_data['submission_wait_seconds'] = submission_wait_seconds + + descriptor = Mock(weight="1") + if problem_state is not None: + field_data.update(problem_state) + if attempts is not None: + # converting to int here because I keep putting "0" and "1" in the tests + # since everything else is a string. + field_data['attempts'] = int(attempts) + + system = get_test_system() + system.render_template = Mock(return_value="
Test Template HTML
") + module = CapaModule( + descriptor, + system, + DictFieldData(field_data), + ScopeIds(None, None, location, location), + ) + + if correct: + # TODO: probably better to actually set the internal state properly, but... + module.get_score = lambda: {'score': 1, 'total': 1} + else: + module.get_score = lambda: {'score': 0, 'total': 1} + + return module