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?