diff --git a/mod/quiz/attempt.php b/mod/quiz/attempt.php index 4517f058e0b..9a705aa6e81 100644 --- a/mod/quiz/attempt.php +++ b/mod/quiz/attempt.php @@ -22,6 +22,10 @@ $timeup = optional_param('timeup', 0, PARAM_BOOL); // True if form was submitted by timer. $forcenew = optional_param('forcenew', false, PARAM_BOOL); // Teacher has requested new preview + // remember the current time as the time any responses were submitted + // (so as to make sure students don't get penalized for slow processing on this page) + $timestamp = time(); + // We treat automatically closed attempts just like normally closed attempts if ($timeup) { $finishattempt = 1; @@ -104,15 +108,6 @@ error(get_string('nomoreattempts', 'quiz'), "view.php?id={$cm->id}"); } - $timenow = time(); - if (($timenow < $quiz->timeopen || $timenow > $quiz->timeclose)) { - if ($isteacher) { - notify(get_string('notavailabletostudents', 'quiz')); - } else { - error(get_string('notavailable', 'quiz'), "view.php?id={$cm->id}"); - } - } - /// Check subnet access if ($quiz->subnet and !address_in_subnet(getremoteaddr(), $quiz->subnet)) { if ($isteacher) { @@ -161,7 +156,7 @@ if ($isteacher and $forcenew) { // teacher wants a new preview // so we set a finish time on the current attempt (if any). // It will then automatically be deleted below - set_field('quiz_attempts', 'timefinish', time(), 'quiz', $quiz->id, 'userid', $USER->id); + set_field('quiz_attempts', 'timefinish', $timestamp, 'quiz', $quiz->id, 'userid', $USER->id); } $attempt = get_record('quiz_attempts', 'quiz', $quiz->id, @@ -204,13 +199,15 @@ } } else { // log continuation of attempt only if some time has lapsed - if ((time() - $attempt->timemodified) > 600) { // 10 minutes have elapsed + if (($timestamp - $attempt->timemodified) > 600) { // 10 minutes have elapsed add_to_log($course->id, 'quiz', 'continue attempt', "review.php?attempt=$attempt->id", "$quiz->id", $cm->id); } } - + if ($attempt->timestart) { // shouldn't really happen, just for robustness + $attempt->timestart = time(); + } /// Load all the questions and states needed by this script @@ -282,11 +279,12 @@ if (!isset($actions[$i])) { $actions[$i]->responses = array('' => ''); } + $actions[$i]->timestamp = $timestamp; quiz_process_responses($questions[$i], $states[$i], $actions[$i], $quiz, $attempt); quiz_save_question_session($questions[$i], $states[$i]); } - $attempt->timemodified = time(); + $attempt->timemodified = $timestamp; // We have now finished processing form data } @@ -296,7 +294,7 @@ if ($finishattempt) { // Set the attempt to be finished - $attempt->timefinish = time(); + $attempt->timefinish = $timestamp; // Find all the questions for this attempt for which the newest // state is not also the newest graded state @@ -327,6 +325,7 @@ foreach($closequestions as $key => $question) { $action->event = QUIZ_EVENTCLOSE; $action->responses = $closestates[$key]->responses; + $action->timestamp = $colsestates[$key]->timestamp; quiz_process_responses($question, $closestates[$key], $action, $quiz, $attempt); quiz_save_question_session($question, $closestates[$key]); } @@ -336,7 +335,6 @@ "$quiz->id", $cm->id); } - /// Update the quiz attempt and the overall grade for the quiz if ($responses || $finishattempt) { if (!update_record('quiz_attempts', $attempt)) { @@ -347,31 +345,21 @@ } } +/// Check access to quiz page + + // check the quiz times + if (($timestamp < $quiz->timeopen || $timestamp > $quiz->timeclose)) { + if ($isteacher) { + notify(get_string('notavailabletostudents', 'quiz')); + } else { + print_continue(get_string('notavailable', 'quiz'), "view.php?id={$cm->id}"); + } + } + if ($finishattempt) { redirect('review.php?attempt='.$attempt->id); } -/// Get time limit if any. - $timelimit = $quiz->timelimit * 60; - - if ($timelimit > 0) { - $timestart = $attempt->timestart; - if ($timestart) { - $timesincestart = $timenow - $timestart; - $timerstartvalue = $timelimit - $timesincestart; - } else { - $timerstartvalue = $timelimit; - } - if ($timerstartvalue <= 0) { - $timerstartvalue = 1; - } - if(($timelimit + 60) <= $timesincestart) { - // To pass it on to quiz_grade_responses - $quiz->timesincestart = $timesincestart; - } - } - - /// Print the quiz page //////////////////////////////////////////////////////// /// Print the attempt number or preview heading @@ -387,12 +375,6 @@ print_heading($strattemptnum); } -/// Add the javascript timer in the title bar if the closing time appears close - $secondsleft = $quiz->timeclose - time(); - if ($secondsleft > 0 and $secondsleft < 24*3600) { // less than a day remaining - include('jsclock.php'); - } - /// Start the form if($quiz->timelimit > 0) { // Make sure javascript is enabled for time limited quizzes @@ -472,9 +454,23 @@ // Finish the form echo "\n"; + + $secondsleft = $quiz->timeclose - time(); // If time limit is set include floating timer. - if ($timelimit > 0) { + if ($quiz->timelimit > 0) { + + $timesincestart = time() - $attempt->timestart; + $timerstartvalue = min($quiz->timelimit*60 - $timesincestart, $secondsleft); + if ($timerstartvalue <= 0) { + $timerstartvalue = 1; + } + require('jstimer.php'); + } else { + // Add the javascript timer in the title bar if the closing time appears close + if ($secondsleft > 0 and $secondsleft < 24*3600) { // less than a day remaining + include('jsclock.php'); + } } if (!$isteacher) { diff --git a/mod/quiz/doc/eventtypes.html b/mod/quiz/doc/eventtypes.html new file mode 100644 index 00000000000..112db1078b7 --- /dev/null +++ b/mod/quiz/doc/eventtypes.html @@ -0,0 +1,38 @@ + + + Event types + + + + +

Event types

+ +

The $state object (and the quiz_states table) has a field $event +which indicates the event that led to the state's creation. The +field can take the value of any of the following constants (defined +in locallib.php):

+ + + +

When new responses are being processed by +quiz_process_responses() then this function is being passed the +event type in $action->event while the responses are in +$action->responses.

+ + diff --git a/mod/quiz/doc/penalties.html b/mod/quiz/doc/penalties.html index 89100fa8c41..cbec720314c 100644 --- a/mod/quiz/doc/penalties.html +++ b/mod/quiz/doc/penalties.html @@ -60,6 +60,15 @@ per response.

Where it is done in the code

+The function quiz_apply_penalty_and_timelimit() subtracts the penalty in +$state->sumpenalty from the raw grade in $state->raw_grade to obtain +$state->grade for the response. However it is ensured that the grade +of a new attempt at the question never falls below the previously +achieved grade. This function also increases $state->sumpenalty by +the amount in $state->penalty. The assumption is that +$state->penalty has just been set appropriately by the code calling +this function, e.g., quiz_process_responses. +

About wrapped questions

diff --git a/mod/quiz/doc/timelimit.html b/mod/quiz/doc/timelimit.html new file mode 100644 index 00000000000..a7642dff58e --- /dev/null +++ b/mod/quiz/doc/timelimit.html @@ -0,0 +1,39 @@ + + + Time limit + + + + +

Time limit

+ +

A quiz can have a time limit. This is stored in minutes in +$quiz->timelimit. So before using this in time calculations it +always has to be multiplied by 60 to turn it into seconds like all +other timestamps in moodle and php. If $quiz->timelimit is zero it +means there is no timelimit.

+ +

If a student asks to start an attempt on view.php for a quiz with +a timelimit then he is shown a javascript message alerting him to +the timelimit and is asked to confirm.

+ +

For quizzes with timelimit attempt.php shows a javascript timer +that counts down and automatically submits and closes the attempt +when the time is up.

+ +

Confusingly there are two javascript timers in the quiz module. +jsclock.php provides a countdown in the title bar that counts down +to the quiz closing time if this is less than a day away. This has +nothing to do with the timelimit. jstimer.php provides the countdown +timer that implements the timelimit. It in turn uses timer.js.

+ +

The time a response was submitted by the student is recorded by +attempt.php right at the top of the page and is then passed on to +quiz_process_responses in $action->timestamp. This puts it into +$state->timestamp. Finally, after the responses have been graded, +the function quiz_apply_penalty_and_timelimit() checks that the +responses are within the timelimit to within 5% and if not it sets +the grade to zero (or the previously obtained grade, if that is +higher).

+ + diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 744ac04bdfe..6ffbfe759c0 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -1233,8 +1233,7 @@ function quiz_restore_state(&$question, &$state) { // Set the changed field to false; any code which changes the // question session must set this to true and must increment - // ->seq_number; it can do this by calling - // quiz_mark_session_change. The quiz_save_question_session + // ->seq_number. The quiz_save_question_session // function will save the new state object database if the field is // set to true. $state->changed = false; @@ -1484,8 +1483,10 @@ function quiz_regrade_question_in_quizzes($question, $quizlist) { * @param object $question Full question object, passed by reference * @param object $state Full state object, passed by reference * @param object $action object with the fields ->responses which -* is an array holding the student responses and -* ->action which specifies the action, e.g., QUIZ_EVENTGRADE +* is an array holding the student responses, +* ->action which specifies the action, e.g., QUIZ_EVENTGRADE, +* and ->timestamp which is a timestamp from when the responses +* were submitted by the student. * @param object $quiz The quiz object * @param object $attempt The attempt is passed by reference so that * during grading its ->sumgrades field can be updated @@ -1510,7 +1511,7 @@ function quiz_process_responses(&$question, &$state, $action, $quiz, &$attempt) } // Check if we are grading the question; compare against last graded // responses, not last given responses in this case - if (QUIZ_EVENTGRADE == $action->event || QUIZ_EVENTCLOSE == $action->event) { + if (quiz_isgradingevent($action->event)) { $state->responses = $state->last_graded->responses; } // Check for unchanged responses (exactly unchanged, not equivalent). @@ -1518,7 +1519,7 @@ function quiz_process_responses(&$question, &$state, $action, $quiz, &$attempt) $sameresponses = (($state->responses == $action->responses) or ($state->responses == array(''=>'') && array_keys(array_count_values($action->responses))===array(''))); - if ($sameresponses && isset($action->event) and QUIZ_EVENTCLOSE != $action->event + if ($sameresponses and QUIZ_EVENTCLOSE != $action->event and QUIZ_EVENTVALIDATE != $action->event) { return true; } @@ -1527,9 +1528,10 @@ function quiz_process_responses(&$question, &$state, $action, $quiz, &$attempt) // responses $newstate = clone($state->last_graded); $newstate->responses = $action->responses; - $newstate->seq_number = $state->seq_number; - $newstate->changed = false; + $newstate->seq_number = $state->seq_number + 1; + $newstate->changed = true; // will assure that it gets saved to the database $newstate->last_graded = $state->last_graded; + $newstate->timestamp = $action->timestamp; $state = $newstate; // Set the event to the action we will perform. The question type specific @@ -1537,7 +1539,7 @@ function quiz_process_responses(&$question, &$state, $action, $quiz, &$attempt) // attempt at the question causes the session to close $state->event = $action->event; - if (QUIZ_EVENTSAVE == $action->event || QUIZ_EVENTVALIDATE == $action->event) { + if (!quiz_isgradingevent($action->event)) { // Grade the response but don't update the overall grade $QUIZ_QTYPES[$question->qtype]->grade_responses( $question, $state, $quiz); @@ -1571,7 +1573,7 @@ function quiz_process_responses(&$question, &$state, $action, $quiz, &$attempt) $QUIZ_QTYPES[$question->qtype]->grade_responses( $question, $state, $quiz); // Calculate overall grade using correct penalty method - quiz_apply_penalty($question, $state, $quiz); + quiz_apply_penalty_and_timelimit($question, $state, $attempt, $quiz); // Update the last graded state (don't simplify!) unset($state->last_graded); $state->last_graded = clone($state); @@ -1589,7 +1591,7 @@ function quiz_process_responses(&$question, &$state, $action, $quiz, &$attempt) $QUIZ_QTYPES[$question->qtype]->grade_responses( $question, $state, $quiz); // Calculate overall grade using correct penalty method - quiz_apply_penalty($question, $state, $quiz); + quiz_apply_penalty_and_timelimit($question, $state, $attempt, $quiz); } // Force the state to close (as the attempt is closing) $state->event = QUIZ_EVENTCLOSE; @@ -1606,11 +1608,17 @@ function quiz_process_responses(&$question, &$state, $action, $quiz, &$attempt) $attempt->sumgrades += (float)$state->last_graded->grade; } - quiz_mark_session_change($state); - $attempt->timemodified = time(); + $attempt->timemodified = $action->timestamp; return true; } +/** +* Determine if event requires grading +*/ +function quiz_isgradingevent($event) { + return (QUIZ_EVENTGRADE == $event || QUIZ_EVENTCLOSE == $event); +} + /** * Compare current responses to all previous graded responses * @@ -1645,13 +1653,14 @@ function quiz_search_for_duplicate_responses(&$question, &$state) { } /** -* Applies the penalty for the previous attempts to the raw grade for the current +* Applies the penalty from the previous attempts to the raw grade for the current * attempt * -* The grade for the question in the current state is computed by applying the -* penalty accumulated over the previous marked attempts at the question to the -* raw grade using the penalty scheme in use in the quiz. The ->grade field of -* the state object is modified to reflect the new grade. +* The grade for the question in the current state is computed by subtracting the +* penalty accumulated over the previous marked attempts at the question from the +* raw grade. If the timestamp is more than 1 minute beyond the start of the attempt +* the grade is set to zero. The ->grade field of the state object is modified to +* reflect the new grade but is never allowed to decrease. * @param object $question The question for which the penalty is to be applied. * @param object $state The state for which the grade is to be set from the * raw grade and the cumulative penalty from the last @@ -1661,13 +1670,28 @@ function quiz_search_for_duplicate_responses(&$question, &$state) { * @param object $quiz The quiz to which the question belongs. The penalty * scheme to apply is given by the ->penaltyscheme field. */ -function quiz_apply_penalty(&$question, &$state, $quiz) { +function quiz_apply_penalty_and_timelimit(&$question, &$state, $attempt, $quiz) { + // deal with penaly if ($quiz->penaltyscheme) { $state->grade = $state->raw_grade - $state->sumpenalty; $state->sumpenalty += (float) $state->penalty; } else { $state->grade = $state->raw_grade; } + + // deal with timeimit + if ($quiz->timelimit) { + // We allow for 5% uncertainty in the following test + if (($state->timestamp - $attempt->timestart) > ($quiz->timelimit * 63)) { + $state->grade = 0; + } + } + + // deal with quiz closing time + if ($state->timestamp > ($quiz->timeclose + 60)) { // allowing 1 minute lateness + $state->grade = 0; + } + // Ensure that the grade does not go down $state->grade = max($state->grade, $state->last_graded->grade); } diff --git a/mod/quiz/preview.php b/mod/quiz/preview.php index ccafbb1d83e..487cfe49008 100644 --- a/mod/quiz/preview.php +++ b/mod/quiz/preview.php @@ -150,6 +150,7 @@ $event = $finishattempt ? QUIZ_EVENTCLOSE : ($markall ? QUIZ_EVENTGRADE : QUIZ_EVENTSAVE); if ($actions = quiz_extract_responses($questions, $form, $event)) { + $actions[$id]->timestamp = 0; // We do not care about timelimits here quiz_process_responses($questions[$id], $states[$historylength][$id], $actions[$id], $quiz, $attempt); if (QUIZ_EVENTGRADE != $curstate->event && QUIZ_EVENTCLOSE != $curstate->event) { // Update the current state rather than creating a new one