diff --git a/lang/en/question.php b/lang/en/question.php index 1edd07787aa..10f7c99d2fb 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -397,9 +397,7 @@ $string['questiontext'] = 'Question text'; $string['requiresgrading'] = 'Requires grading'; $string['responsehistory'] = 'Response history'; $string['restart'] = 'Start again'; -$string['restartquestion'] = 'Restart question'; $string['restartwiththeseoptions'] = 'Start again with these options'; -$string['updatedisplayoptions'] = 'Update display options'; $string['rightanswer'] = 'Right answer'; $string['rightanswer_help'] = 'an automatically generated summary of the correct response. This can be limited, so you may wish to consider explaining the correct solution in the general feedback for the question, and turning this option off.'; $string['saved'] = 'Saved: {$a}'; diff --git a/mod/quiz/attempt.php b/mod/quiz/attempt.php index 5ac9659b239..181c9bc80c4 100644 --- a/mod/quiz/attempt.php +++ b/mod/quiz/attempt.php @@ -125,12 +125,6 @@ if ($attemptobj->get_currentpage() != $page) { $DB->set_field('quiz_attempts', 'currentpage', $page, array('id' => $attemptid)); } -// Process replace question action, when user press on 'Replace question' link. -$replacequestioninslot = optional_param('replacequestioninslot', 0, PARAM_INT); -if ($replacequestioninslot) { - $attemptobj->process_replace_question_actions($replacequestioninslot, time()); -} - // Initialise the JavaScript. $headtags = $attemptobj->get_html_head_contributions($page); $PAGE->requires->js_init_call('M.mod_quiz.init_attempt_form', null, false, quiz_get_js_module()); diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index 75a603307dc..aeb882f2793 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -951,11 +951,11 @@ class quiz_attempt { } /** - * Return the list of question ids for either a given page of the quiz, or for the + * Return the list of slot numbers for either a given page of the quiz, or for the * whole quiz. * * @param mixed $page string 'all' or integer page number. - * @return array the reqested list of question ids. + * @return array the requested list of slot numbers. */ public function get_slots($page = 'all') { if ($page === 'all') { @@ -969,6 +969,23 @@ class quiz_attempt { } } + /** + * Return the list of slot numbers for either a given page of the quiz, or for the + * whole quiz. + * + * @param mixed $page string 'all' or integer page number. + * @return array the requested list of slot numbers. + */ + public function get_active_slots($page = 'all') { + $activeslots = array(); + foreach ($this->get_slots($page) as $slot) { + if (!$this->is_blocked_by_previous_question($slot)) { + $activeslots[] = $slot; + } + } + return $activeslots; + } + /** * Get the question_attempt object for a particular question in this attempt. * @param int $slot the number used to identify this question within this attempt. @@ -978,6 +995,22 @@ class quiz_attempt { return $this->quba->get_question_attempt($slot); } + /** + * Get the question_attempt object for a particular question in this attempt. + * @param int $slot the number used to identify this question within this attempt. + * @return question_attempt + */ + public function all_question_attempts_originally_in_slot($slot) { + $qas = array(); + foreach ($this->quba->get_attempt_iterator() as $qa) { + if ($qa->get_metadata('originalslot') == $slot) { + $qas[] = $qa; + } + } + $qas[] = $this->quba->get_question_attempt($slot); + return $qas; + } + /** * Is a particular question in this attempt a real question, or something like a description. * @param int $slot the number used to identify this question within this attempt. @@ -1004,13 +1037,39 @@ class quiz_attempt { * @return bool whether the previous question must have been completed before this one can be seen. */ public function is_blocked_by_previous_question($slot) { - return $slot > 1 && $this->slots[$slot]->requireprevious && + return $slot > 1 && isset($this->slots[$slot]) && $this->slots[$slot]->requireprevious && !$this->get_quiz()->shufflequestions && $this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ && - !$this->quba->get_question_state($slot - 1)->is_finished() && + !$this->get_question_state($slot - 1)->is_finished() && $this->quba->can_question_finish_during_attempt($slot - 1); } + /** + * Is it possible for this question to be re-started within this attempt? + * + * @param int $slot the number used to identify this question within this attempt. + * @return whether the student should be given the option to restart this question now. + */ + public function can_question_be_redone_now($slot) { + return $this->get_quiz()->canredoquestions && !$this->is_finished() && + $this->get_question_state($slot)->is_finished(); + } + + /** + * Given a slot in this attempt, which may or not be a redone question, return the original slot. + * + * @param int $slot identifies a particular question in this attempt. + * @return int the slot where this question was originally. + */ + public function get_original_slot($slot) { + $originalslot = $this->quba->get_question_attempt_metadata($slot, 'originalslot'); + if ($originalslot) { + return $originalslot; + } else { + return $slot; + } + } + /** * Get the displayed question number for a slot. * @param int $slot the number used to identify this question within this attempt. @@ -1042,6 +1101,16 @@ class quiz_attempt { return $this->quba->get_question($slot)->name; } + /** + * Return the {@link question_state} that this question is in. + * + * @param int $slot the number used to identify this question within this attempt. + * @return question_state the state this question is in. + */ + public function get_question_state($slot) { + return $this->quba->get_question_state($slot); + } + /** * Return the grade obtained on a particular question, if the user is permitted * to see it. You must previously have called load_question_states to load the @@ -1275,12 +1344,13 @@ class quiz_attempt { * Generate the HTML that displayes the question in its current state, with * the appropriate display options. * - * @param int $id the id of a question in this quiz attempt. + * @param int $slot identifies the question in the attempt. * @param bool $reviewing is the being printed on an attempt or a review page. + * @param mod_quiz_renderer $renderer the quiz renderer. * @param moodle_url $thispageurl the URL of the page this question is being printed on. * @return string HTML for the question in its current state. */ - public function render_question($slot, $reviewing, $thispageurl = null) { + public function render_question($slot, $reviewing, mod_quiz_renderer $renderer, $thispageurl = null) { if ($this->is_blocked_by_previous_question($slot)) { $placeholderqa = $this->make_blocked_question_placeholder($slot); @@ -1290,20 +1360,64 @@ class quiz_attempt { $displayoptions->readonly = true; return html_writer::div($placeholderqa->render($displayoptions, - $this->get_question_number($slot)), + $this->get_question_number($this->get_original_slot($slot))), 'mod_quiz-blocked_question_warning'); } - return $this->quba->render_question($slot, - $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl), - $this->get_question_number($slot)); + return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, null); + } + + /** + * Helper used by {@link render_question()} and {@link render_question_at_step()}. + * + * @param int $slot identifies the question in the attempt. + * @param bool $reviewing is the being printed on an attempt or a review page. + * @param moodle_url $thispageurl the URL of the page this question is being printed on. + * @param mod_quiz_renderer $renderer the quiz renderer. + * @param int|null $seq the seq number of the past state to display. + * @return string HTML fragment. + */ + protected function render_question_helper($slot, $reviewing, $thispageurl, mod_quiz_renderer $renderer, $seq) { + $originalslot = $this->get_original_slot($slot); + $number = $this->get_question_number($originalslot); + $displayoptions = $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl); + + if ($slot != $originalslot) { + $originalmaxmark = $this->get_question_attempt($slot)->get_max_mark(); + $this->get_question_attempt($slot)->set_max_mark($this->get_question_attempt($originalslot)->get_max_mark()); + } + + if ($this->can_question_be_redone_now($slot)) { + $displayoptions->extrainfocontent = $renderer->redo_question_button( + $slot, $displayoptions->readonly); + } + + if ($displayoptions->history && $displayoptions->questionreviewlink) { + $links = $this->links_to_other_redos($slot, $displayoptions->questionreviewlink); + if ($links) { + $displayoptions->extrahistorycontent = html_writer::tag('p', + get_string('redoesofthisquestion', 'quiz', $renderer->render($links))); + } + } + + if ($seq === null) { + $output = $this->quba->render_question($slot, $displayoptions, $number); + } else { + $output = $this->quba->render_question_at_step($slot, $seq, $displayoptions, $number); + } + + if ($slot != $originalslot) { + $this->get_question_attempt($slot)->set_max_mark($originalmaxmark); + } + + return $output; } /** * Create a fake question to be displayed in place of a question that is blocked * until the previous question has been answered. * - * @param unknown $slot int slot number of the question to replace. + * @param int $slot int slot number of the question to replace. * @return question_definition the placeholde question. */ protected function make_blocked_question_placeholder($slot) { @@ -1345,13 +1459,12 @@ class quiz_attempt { * @param int $id the id of a question in this quiz attempt. * @param int $seq the seq number of the past state to display. * @param bool $reviewing is the being printed on an attempt or a review page. + * @param mod_quiz_renderer $renderer the quiz renderer. * @param string $thispageurl the URL of the page this question is being printed on. * @return string HTML for the question in its current state. */ - public function render_question_at_step($slot, $seq, $reviewing, $thispageurl = '') { - return $this->quba->render_question_at_step($slot, $seq, - $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl), - $this->get_question_number($slot)); + public function render_question_at_step($slot, $seq, $reviewing, mod_quiz_renderer $renderer, $thispageurl = '') { + return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, $seq); } /** @@ -1401,11 +1514,18 @@ class quiz_attempt { } /** - * Given a URL containing attempt={this attempt id}, return an array of variant URLs + * Return an array of variant URLs to other attempts at this quiz. + * + * The $url passed in must contain an attempt parameter. + * + * The {@link mod_quiz_links_to_other_attempts} object returned contains an + * array with keys that are the attempt number, 1, 2, 3. + * The array values are either a {@link moodle_url} with the attmept parameter + * updated to point to the attempt id of the other attempt, or null corresponding + * to the current attempt number. + * * @param moodle_url $url a URL. - * @return string HTML fragment. Comma-separated list of links to the other - * attempts with the attempt number as the link text. The curent attempt is - * included but is not a link. + * @return mod_quiz_links_to_other_attempts containing array int => null|moodle_url. */ public function links_to_other_attempts(moodle_url $url) { $attempts = quiz_get_user_attempts($this->get_quiz()->id, $this->attempt->userid, 'all'); @@ -1424,6 +1544,47 @@ class quiz_attempt { return $links; } + /** + * Return an array of variant URLs to other redos of the question in a particular slot. + * + * The $url passed in must contain a slot parameter. + * + * The {@link mod_quiz_links_to_other_attempts} object returned contains an + * array with keys that are the redo number, 1, 2, 3. + * The array values are either a {@link moodle_url} with the slot parameter + * updated to point to the slot that has that redo of this question; or null + * corresponding to the redo identified by $slot. + * + * @param int $slot identifies a question in this attempt. + * @param moodle_url $baseurl the base URL to modify to generate each link. + * @return mod_quiz_links_to_other_attempts|null containing array int => null|moodle_url, + * or null if the question in this slot has not been redone. + */ + public function links_to_other_redos($slot, moodle_url $baseurl) { + $originalslot = $this->get_original_slot($slot); + + $qas = $this->all_question_attempts_originally_in_slot($originalslot); + if (count($qas) <= 1) { + return null; + } + + $links = new mod_quiz_links_to_other_attempts(); + $index = 1; + foreach ($qas as $qa) { + if ($qa->get_slot() == $slot) { + $links->links[$index] = null; + } else { + $url = new moodle_url($baseurl, array('slot' => $qa->get_slot())); + $links->links[$index] = new action_link($url, $index, + new popup_action('click', $url, 'reviewquestion', + array('width' => 450, 'height' => 650)), + array('title' => get_string('reviewresponse', 'question'))); + } + $index++; + } + return $links; + } + // Methods for processing ================================================== /** @@ -1528,43 +1689,56 @@ class quiz_attempt { } /** - * Process replace question action - * @param int $slot - * @param int $timestamp + * Replace a question in an attempt with a new attempt at the same qestion. + * @param int $slot the questoin to restart. + * @param int $timestamp the timestamp to record for this action. */ - public function process_replace_question_actions($slot, $timestamp) { + public function process_redo_question($slot, $timestamp) { global $DB; + if (!$this->can_question_be_redone_now($slot)) { + throw new coding_exception('Attempt to restart the question in slot ' . $slot . + ' when it is not in a state to be restarted.'); + } + + $qubaids = new \mod_quiz\question\qubaids_for_users_attempts( + $this->get_quizid(), $this->get_userid()); + $transaction = $DB->start_delegated_transaction(); - $this->quba->replace_question($slot); + $questiondata = $DB->get_record('question', + array('id' => $this->slots[$slot]->questionid)); + if ($questiondata->qtype != 'random') { + $newqusetionid = $questiondata->id; + } else { + $randomloader = new \core_question\bank\random_question_loader($qubaids, array()); + $newqusetionid = $randomloader->get_next_question_id($questiondata->category, + (bool) $questiondata->questiontext); + if ($newqusetionid === null) { + throw new moodle_exception('notenoughrandomquestions', 'quiz', + $quizobj->view_url(), $questiondata); + } + } + + $newquestion = question_bank::load_question($newqusetionid); + if ($newquestion->get_num_variants() == 1) { + $variant = 1; + } else { + $variantstrategy = new core_question\engine\variants\least_used_strategy( + $this->quba, $qubaids); + $variant = $variantstrategy->choose_variant($newquestion->get_num_variants(), + $newquestion->get_variants_selection_seed()); + } + + $newslot = $this->quba->add_question_in_place_of_other($slot, $newquestion); + $this->quba->start_question($slot); + $this->quba->set_max_mark($newslot, 0); + $this->quba->set_question_attempt_metadata($newslot, 'originalslot', $slot); question_engine::save_questions_usage_by_activity($this->quba); $transaction->allow_commit(); } - /** - * Return a button which allows students reattempting the current question - * - * @param int $slot, the number of the current slot - */ - public function restart_question_button($slot) { - // If 'reattemptgradedquestions' field is not set, do not display the 'Restart question' button. - if (!$this->get_quiz()->reattemptgradedquestions) { - return; - } - $qa = $this->get_question_attempt($slot); - - // If question is not graded, do not display the 'Restart question' button. - if (!$qa->get_state()->is_graded()) { - return; - } - $buttonvalue = get_string('restartquestion', 'question'); - return html_writer::tag('div', - " - "); - } - /** * Process all the autosaved data that was part of the current request. * diff --git a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php index a15bb810d1d..79fe698f4bb 100644 --- a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php +++ b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php @@ -41,7 +41,7 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru // Define each element separated. $quiz = new backup_nested_element('quiz', array('id'), array( 'name', 'intro', 'introformat', 'timeopen', 'timeclose', 'timelimit', - 'overduehandling', 'graceperiod', 'preferredbehaviour', 'attempts_number', + 'overduehandling', 'graceperiod', 'preferredbehaviour', 'canredoquestions', 'attempts_number', 'attemptonlast', 'grademethod', 'decimalpoints', 'questiondecimalpoints', 'reviewattempt', 'reviewcorrectness', 'reviewmarks', 'reviewspecificfeedback', 'reviewgeneralfeedback', diff --git a/mod/quiz/db/install.xml b/mod/quiz/db/install.xml index 0cfa40de4a2..21bd11977a3 100644 --- a/mod/quiz/db/install.xml +++ b/mod/quiz/db/install.xml @@ -17,7 +17,7 @@ - + diff --git a/mod/quiz/db/upgrade.php b/mod/quiz/db/upgrade.php index b40fe080133..c9b503585cf 100644 --- a/mod/quiz/db/upgrade.php +++ b/mod/quiz/db/upgrade.php @@ -822,9 +822,9 @@ function xmldb_quiz_upgrade($oldversion) { } if ($oldversion < 2015030900) { - // Define field reattemptgradedquestions to be added to quiz. + // Define field canredoquestions to be added to quiz. $table = new xmldb_table('quiz'); - $field = new xmldb_field('reattemptgradedquestions', XMLDB_TYPE_INTEGER, '4', null, null, null, 0, 'completionpass'); + $field = new xmldb_field('canredoquestions', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, 0, 'preferredbehaviour'); // Conditionally launch add field completionpass. if (!$dbman->field_exists($table, $field)) { diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index c45c1f1f3ca..60575e3d813 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -140,6 +140,14 @@ $string['cannotstartgradesmismatch'] = 'Cannot start an attempt at this quiz. Th $string['cannotstartmissingquestion'] = 'Cannot start an attempt at this quiz. The quiz definition includes a question that does not exist.'; $string['cannotstartnoquestions'] = 'Cannot start an attempt at this quiz. The quiz has not been set up yet. No questions have been added.'; $string['cannotwrite'] = 'Cannot write to export file ({$a})'; +$string['canredoquestions'] = 'Allow redo within an attempt'; +$string['canredoquestions_desc'] = 'If enabled, then when students have finished attempting particular question, they will see a Redo question button. This allows them to attempt another version of the same question, without having to submit the entire quiz attempt and start another one. This option is mainly useful for practice quizzes. + +This setting only affects questions (for example not Essay questions) and behaviours (for example Immediate feedback, or Interactive with multiple tries) where it is possible for student to finish the question before the attempt is submitted.'; +$string['canredoquestions_help'] = 'If enabled, then when students have finished attempting particular question, they will see a Redo question button. This allows them to attempt another version of the same question, without having to submit the entire quiz attempt and start another one. This option is mainly useful for practice quizzes. + +This setting only affects questions (for example not Essay questions) and behaviours (for example Immediate feedback, or Interactive with multiple tries) where it is possible for student to finish the question before the attempt is submitted.'; +$string['canredoquestionsyes'] = 'Students may redo another version of any finished question'; $string['caseno'] = 'No, case is unimportant'; $string['casesensitive'] = 'Case sensitivity'; $string['caseyes'] = 'Yes, case must match'; @@ -180,7 +188,6 @@ $string['configpenaltyscheme'] = 'Penalty subtracted for each wrong response in $string['configpopup'] = 'Force the attempt to open in a popup window, and use JavaScript tricks to try to restrict copy and paste, etc. during quiz attempts.'; $string['configrequirepassword'] = 'Students must enter this password before they can attempt the quiz.'; $string['configrequiresubnet'] = 'Students can only attempt the quiz from these computers.'; -$string['configrestartgradedquestions'] = 'If enabled, it allows students to restart graded questions in \'Immediate feedback\', \'Immediate feedback with CBM\' and \'Interactive with multiple tries behaviours\''; $string['configreviewoptions'] = 'These options control what information users can see when they review a quiz attempt or look at the quiz reports.'; $string['configshowblocks'] = 'Show blocks during quiz attempts.'; $string['configshowuserpicture'] = 'Show the user\'s picture on screen during attempts.'; @@ -449,6 +456,7 @@ $string['manualgrading'] = 'Grading'; $string['mark'] = 'Submit'; $string['markall'] = 'Submit page'; $string['marks'] = 'Marks'; +$string['marks_help'] = 'The numerical marks for each question, and the overall attempt score.'; $string['match'] = 'Matching'; $string['matchanswer'] = 'Matching answer'; $string['matchanswerno'] = 'Matching answer {$a}'; @@ -689,6 +697,8 @@ $string['readytosend'] = 'You are about to send your whole quiz to be graded. A $string['reattemptquiz'] = 'Re-attempt quiz'; $string['recentlyaddedquestion'] = 'Recently added question!'; $string['recurse'] = 'Include questions from subcategories too'; +$string['redoquestion'] = 'Redo question'; +$string['redoesofthisquestion'] = 'Other questions attempted here: {$a}'; $string['regrade'] = 'Regrade all attempts'; $string['regradecomplete'] = 'All attempts have been regraded'; $string['regradecount'] = '{$a->changed} out of {$a->attempt} grades were changed'; @@ -753,10 +763,6 @@ $string['reviewbefore'] = 'Allow review while quiz is open'; $string['reviewclosed'] = 'After the quiz is closed'; $string['reviewduring'] = 'During the attempt'; $string['reviewimmediately'] = 'Immediately after the attempt'; -$string['marks'] = 'Marks'; -$string['marks_help'] = 'The numerical marks for each question, and the overall attempt score.'; -$string['restartgradedquestions'] = 'Restart graded questions'; -$string['restartgradedquestions_help'] = 'If enabled, it allows students to restart graded questions in \'Immediate feedback\', \'Immediate feedback with CBM\' and \'Interactive with multiple tries behaviours\''; $string['reviewnever'] = 'Never allow review'; $string['reviewofattempt'] = 'Review of attempt {$a}'; $string['reviewofpreview'] = 'Review of preview'; diff --git a/mod/quiz/mod_form.php b/mod/quiz/mod_form.php index e1fcb1edb9f..2cbd1f18f12 100644 --- a/mod/quiz/mod_form.php +++ b/mod/quiz/mod_form.php @@ -205,17 +205,16 @@ class mod_quiz_mod_form extends moodleform_mod { $mform->addHelpButton('preferredbehaviour', 'howquestionsbehave', 'question'); $mform->setDefault('preferredbehaviour', $quizconfig->preferredbehaviour); - // TODO: Store the 'reattemptgradedquestions' field when new DB structure in place. - $mform->addElement('selectyesno', 'reattemptgradedquestions', get_string('restartgradedquestions', 'quiz')); - $mform->addHelpButton('reattemptgradedquestions', 'restartgradedquestions', 'quiz'); - $mform->setAdvanced('reattemptgradedquestions', $quizconfig->reattemptgradedquestions_adv); - $mform->setDefault('reattemptgradedquestions', $quizconfig->reattemptgradedquestions); + // Can redo completed questions. + $redochoices = array(0 => get_string('no'), 1 => get_string('canredoquestionsyes', 'quiz')); + $mform->addElement('select', 'canredoquestions', get_string('canredoquestions', 'quiz'), $redochoices); + $mform->addHelpButton('canredoquestions', 'canredoquestions', 'quiz'); + $mform->setAdvanced('canredoquestions', $quizconfig->canredoquestions_adv); + $mform->setDefault('canredoquestions', $quizconfig->canredoquestions); foreach ($behaviours as $behaviour => $notused) { - $qbt = question_engine::get_behaviour_type($behaviour); - if (!$qbt->user_can_reattempt_graded_question()) { - $mform->disabledIf('reattemptgradedquestions', 'preferredbehaviour', 'eq', $behaviour); + if (!question_engine::can_questions_finish_during_the_attempt($behaviour)) { + $mform->disabledIf('canredoquestions', 'preferredbehaviour', 'eq', $behaviour); } - } // Each attempt builds on last. diff --git a/mod/quiz/processattempt.php b/mod/quiz/processattempt.php index ff6cedcd581..1b5070ed2cb 100644 --- a/mod/quiz/processattempt.php +++ b/mod/quiz/processattempt.php @@ -44,12 +44,6 @@ $finishattempt = optional_param('finishattempt', false, PARAM_BOOL); $timeup = optional_param('timeup', 0, PARAM_BOOL); // True if form was submitted by timer. $scrollpos = optional_param('scrollpos', '', PARAM_RAW); -// Process replace question action, when user press on 'Replace question' link. -if (isset($_POST['restartquestioninslot'])) { - redirect(new moodle_url('/mod/quiz/attempt.php', - array('attempt' => $attemptid, 'page' => $thispage, 'replacequestioninslot' => $_POST['restartquestionincurrentslot']))); -} - $transaction = $DB->start_delegated_transaction(); $attemptobj = quiz_attempt::create($attemptid); @@ -150,6 +144,14 @@ if (!$finishattempt) { $attemptobj->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); } + if (!$becomingoverdue) { + foreach ($attemptobj->get_slots() as $slot) { + if (optional_param('redoslot' . $slot, false, PARAM_BOOL)) { + $attemptobj->process_redo_question($slot, $timenow); + } + } + } + } else { // The student is too late. $attemptobj->process_going_overdue($timenow, true); diff --git a/mod/quiz/renderer.php b/mod/quiz/renderer.php index 5174dbf79ca..89832c58118 100644 --- a/mod/quiz/renderer.php +++ b/mod/quiz/renderer.php @@ -79,9 +79,9 @@ class mod_quiz_renderer extends plugin_renderer_base { $output .= $this->review_summary_table($summarydata, 0); if (!is_null($seq)) { - $output .= $attemptobj->render_question_at_step($slot, $seq, true); + $output .= $attemptobj->render_question_at_step($slot, $seq, true, $this); } else { - $output .= $attemptobj->render_question($slot, true); + $output .= $attemptobj->render_question($slot, true, $this); } $output .= $this->close_window_button(); @@ -182,7 +182,7 @@ class mod_quiz_renderer extends plugin_renderer_base { mod_quiz_display_options $displayoptions) { $output = ''; foreach ($slots as $slot) { - $output .= $attemptobj->render_question($slot, $reviewing, + $output .= $attemptobj->render_question($slot, $reviewing, $this, $attemptobj->review_url($slot, $page, $showall)); } return $output; @@ -382,10 +382,12 @@ class mod_quiz_renderer extends plugin_renderer_base { mod_quiz_links_to_other_attempts $links) { $attemptlinks = array(); foreach ($links->links as $attempt => $url) { - if ($url) { - $attemptlinks[] = html_writer::link($url, $attempt); - } else { + if (!$url) { $attemptlinks[] = html_writer::tag('strong', $attempt); + } else if ($url instanceof renderable) { + $attemptlinks[] = $this->render($url); + } else { + $attemptlinks[] = html_writer::link($url, $attempt); } } return implode(', ', $attemptlinks); @@ -459,9 +461,8 @@ class mod_quiz_renderer extends plugin_renderer_base { // Print all the questions. foreach ($slots as $slot) { - $output .= $attemptobj->render_question($slot, false, - $attemptobj->attempt_url($slot, $page)); - $output .= $attemptobj->restart_question_button($slot); + $output .= $attemptobj->render_question($slot, false, $this, + $attemptobj->attempt_url($slot, $page), $this); } $output .= html_writer::start_tag('div', array('class' => 'submitbtns')); @@ -487,7 +488,7 @@ class mod_quiz_renderer extends plugin_renderer_base { // if you navigate before the form has finished loading, it does not wipe all // the student's answers. $output .= html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'slots', - 'value' => implode(',', $slots))); + 'value' => implode(',', $attemptobj->get_active_slots($page)))); // Finish the form. $output .= html_writer::end_tag('div'); @@ -498,6 +499,22 @@ class mod_quiz_renderer extends plugin_renderer_base { return $output; } + /** + * Render a button which allows students to redo a question in the attempt. + * + * @param int $slot the number of the slot to generate the button for. + * @param bool $disabled if true, output the button disabled. + * @return string HTML fragment. + */ + public function redo_question_button($slot, $disabled) { + $attributes = array('type' => 'submit', 'name' => 'redoslot' . $slot, + 'value' => get_string('redoquestion', 'quiz'), 'class' => 'mod_quiz-redo_question_button'); + if ($disabled) { + $attributes['disabled'] = 'disabled'; + } + return html_writer::div(html_writer::empty_tag('input', $attributes)); + } + /** * Output the JavaScript required to initialise the countdown timer. * @param int $timerstartvalue time remaining, in seconds. @@ -1185,6 +1202,7 @@ class mod_quiz_renderer extends plugin_renderer_base { class mod_quiz_links_to_other_attempts implements renderable { /** * @var array string attempt number => url, or null for the current attempt. + * url may be either a moodle_url, or a renderable. */ public $links = array(); } diff --git a/mod/quiz/settings.php b/mod/quiz/settings.php index 4ef3dc4e711..63602172fe4 100644 --- a/mod/quiz/settings.php +++ b/mod/quiz/settings.php @@ -128,10 +128,11 @@ if ($ADMIN->fulltree) { get_string('howquestionsbehave', 'question'), get_string('howquestionsbehave_desc', 'quiz'), 'deferredfeedback')); - // Restart completed questions (reattemptgradedquestions). - $quizsettings->add(new admin_setting_configcheckbox_with_advanced('quiz/reattemptgradedquestions', - get_string('restartgradedquestions', 'quiz'), get_string('configrestartgradedquestions', 'quiz'), - array('value' => 0, 'adv' => true))); + // Can redo completed questions. + $quizsettings->add(new admin_setting_configselect_with_advanced('quiz/canredoquestions', + get_string('canredoquestions', 'quiz'), get_string('canredoquestions_desc', 'quiz'), + array('value' => 0, 'adv' => true), + array(0 => get_string('no'), 1 => get_string('canredoquestionsyes', 'quiz')))); // Each attempt builds on last. $quizsettings->add(new admin_setting_configcheckbox_with_advanced('quiz/attemptonlast', diff --git a/mod/quiz/styles.css b/mod/quiz/styles.css index 817caea6366..7f578648a7e 100644 --- a/mod/quiz/styles.css +++ b/mod/quiz/styles.css @@ -24,8 +24,12 @@ text-align: right; } -#page-mod-quiz-attempt .resatrt-question-btn { - font-size: 0.75em; +.path-mod-quiz .mod_quiz-redo_question_button { + margin: 0; +} +.path-mod-quiz input[type="submit"].mod_quiz-redo_question_button { + padding: 2px 0.8em; + font-size: 1em; } #page-mod-quiz-attempt .mod_quiz-blocked_question_warning .que .formulation, diff --git a/mod/quiz/tests/behat/attempt.feature b/mod/quiz/tests/behat/attempt_basic.feature similarity index 100% rename from mod/quiz/tests/behat/attempt.feature rename to mod/quiz/tests/behat/attempt_basic.feature diff --git a/mod/quiz/tests/behat/attempt_redo_questions.feature b/mod/quiz/tests/behat/attempt_redo_questions.feature new file mode 100644 index 00000000000..ec38b60c9e0 --- /dev/null +++ b/mod/quiz/tests/behat/attempt_redo_questions.feature @@ -0,0 +1,110 @@ +@mod @mod_quiz +Feature: Allow students to redo questions in a practice quiz, without starting a whole new attempt + In order to practice particular skills I am struggling with + As a student + I need to be able to redo each question in a quiz as often as necessary without starting a whole new attempt, if my teacher allows it. + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student | Student | One | student@moodle.com | + | teacher | Teacher | One | teacher@moodle.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | student | C1 | student | + | teacher | C1 | teacher | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Test questions | truefalse | TF1 | First question | + | Test questions | truefalse | TF2 | Second question | + And the following "activities" exist: + | activity | name | intro | course | idnumber | preferredbehaviour | canredoquestions | + | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | 1 | + And quiz "Quiz 1" contains the following questions: + | question | page | maxmark | + | TF1 | 1 | 2 | + | TF2 | 1 | 1 | + And I log in as "student" + And I follow "Course 1" + + @javascript + Scenario: After completing a question, there is a redo question button that restarts the question + When I follow "Quiz 1" + And I press "Attempt quiz now" + And I click on "False" "radio" in the "First question" "question" + And I click on "Check" "button" in the "First question" "question" + And I press "Redo question" + Then the state of "First question" question is shown as "Not complete" + And I should see "Marked out of 2.00" in the "First question" "question" + + @javascript + Scenario: The redo question button is visible but disabled for teachers + When I follow "Quiz 1" + And I press "Attempt quiz now" + And I click on "False" "radio" in the "First question" "question" + And I click on "Check" "button" in the "First question" "question" + And I log out + And I log in as "teacher" + And I follow "Course 1" + And I follow "Quiz 1" + And I follow "Attempts: 1" + And I follow "Review attempt" + Then the "Redo question" "button" should be disabled + + @javascript + Scenario: The redo question buttons are no longer visible after the attempt is submitted. + When I follow "Quiz 1" + And I press "Attempt quiz now" + And I click on "False" "radio" in the "First question" "question" + And I click on "Check" "button" in the "First question" "question" + And I press "Next" + And I press "Submit all and finish" + And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue" + Then "Redo question" "button" should not exist + + @javascript + Scenario: Teachers reviewing can see all the qestions attempted in a slot + When I follow "Quiz 1" + And I press "Attempt quiz now" + And I click on "False" "radio" in the "First question" "question" + And I click on "Check" "button" in the "First question" "question" + And I press "Redo question" + And I press "Next" + And I press "Submit all and finish" + And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue" + And I log out + And I log in as "teacher" + And I follow "Course 1" + And I follow "Quiz 1" + And I follow "Attempts: 1" + And I follow "Review attempt" + And I click on "1" "link" in the "First question" "question" + And I switch to "reviewquestion" window + Then the state of "First question" question is shown as "Incorrect" + And I click on "1" "link" in the "First question" "question" + And the state of "First question" question is shown as "Not complete" + And I switch to the main window + And the state of "First question" question is shown as "Not answered" + And I should not see "Submit" in the ".history" "css_element" + And I navigate to "Statistics" node in "Quiz administration > Results" + And I follow "TF1" + And "False" row "Frequency" column of "quizresponseanalysis" table should contain "100.00%" + And "True" row "Frequency" column of "quizresponseanalysis" table should contain "0.00%" + And "[No response]" row "Frequency" column of "quizresponseanalysis" table should contain "100.00%" + + @javascript + Scenario: Redoing question 1 should save any changes to question 2 on the same page + When I follow "Quiz 1" + And I press "Attempt quiz now" + And I click on "False" "radio" in the "First question" "question" + And I click on "Check" "button" in the "First question" "question" + And I click on "True" "radio" in the "Second question" "question" + And I press "Redo question" + And I click on "Check" "button" in the "Second question" "question" + Then the state of "Second question" question is shown as "Correct" diff --git a/mod/quiz/tests/behat/reattemptquestions.feature b/mod/quiz/tests/behat/reattemptquestions.feature deleted file mode 100644 index 0943cc6b3fd..00000000000 --- a/mod/quiz/tests/behat/reattemptquestions.feature +++ /dev/null @@ -1,53 +0,0 @@ -@mod @mod_quiz -Feature: Add a quiz - In order to allow students re-attempting graded question - As a teacher - I need to create a quiz, set 'Restart question' field to 'Yes', add questions to the quiz which can be graded automatically. - - Background: - Given the following "users" exist: - | username | firstname | lastname | email | - | teacher1 | T1 | Teacher1 | teacher1@moodle.com | - | student1 | S1 | Student1 | student1@moodle.com | - And the following "courses" exist: - | fullname | shortname | category | - | Course 1 | C1 | 0 | - And the following "course enrolments" exist: - | user | course | role | - | teacher1 | C1 | editingteacher | - | student1 | C1 | student | - When I log in as "teacher1" - And I follow "Course 1" - And I turn editing mode on - - And I add a "Quiz" to section "1" and I fill the form with: - | Name | Quiz 1 | - | Description | Quiz 1 description | - | How questions behave | Immediate feedback | - | Restart graded questions | Yes | - - And I add a "True/False" question to the "Quiz 1" quiz with: - | Question name | TF001 | - | Question text | Answer question TF001 | - | General feedback | Thank you, this is the general feedback | - | Correct answer | False | - | Feedback for the response 'True'. | So you think it is true | - | Feedback for the response 'False'. | So you think it is false | - And I log out - - @javascript - Scenario: Log in as a student, attempt the quiz and checking whether you can re-attempt a graded question in the appropriate behaviour settings - And I log in as "student1" - And I follow "Course 1" - And I follow "Quiz 1" - And I press "Attempt quiz now" - Then I should see "TF001" - And I should see "Answer question TF001" - And I set the field "True" to "1" - And I press "Check" - And I should see "Incorrect" - Then I press "Restart question" - And I should see "Not complete" - And I set the field "False" to "1" - And I press "Check" - And I should see "Correct" diff --git a/mod/quiz/upgrade.txt b/mod/quiz/upgrade.txt index 5db9d21c156..57842803821 100644 --- a/mod/quiz/upgrade.txt +++ b/mod/quiz/upgrade.txt @@ -11,6 +11,16 @@ This files describes API changes in the quiz code. + initialise_editing_javascript has had some redundant arguments removed. Hopefully, with these changes, we will have less need to make other changes in future. +* Due to MDL-40992, you should be aware that extra slots can get added to an attempt. + You may get slot numbers beyone the end of the original quiz layout, and you + may want to call $attemptobj->get_original_slot to find where the question + originally came from. + +* You now need to pass an instance of the mod_quiz_renderer if you call + $attemptobj->render_question or $attemptobj->render_question_at_step. + +* The array values in mod_quiz_links_to_other_attempts may now be either a moodle_url, + or renderable (or null). Previously they could only be a moodle_url or null. === 2.8 === diff --git a/question/behaviour/informationitem/renderer.php b/question/behaviour/informationitem/renderer.php index 29c5f8c0829..99a78529868 100644 --- a/question/behaviour/informationitem/renderer.php +++ b/question/behaviour/informationitem/renderer.php @@ -36,7 +36,7 @@ defined('MOODLE_INTERNAL') || die(); */ class qbehaviour_informationitem_renderer extends qbehaviour_renderer { public function controls(question_attempt $qa, question_display_options $options) { - if ($qa->get_state() != question_state::$todo) { + if ($options->readonly || $qa->get_state() != question_state::$todo) { return ''; } diff --git a/question/engine/lib.php b/question/engine/lib.php index 03d6dae8a0c..516e3204e0b 100644 --- a/question/engine/lib.php +++ b/question/engine/lib.php @@ -591,6 +591,21 @@ class question_display_options { */ public $history = self::HIDDEN; + /** + * @since 2.9 + * @var string extra HTML to include in the info box of the question display. + * This is normally shown after the information about the question, and before + * any controls like the flag or the edit icon. + */ + public $extrainfocontent = ''; + + /** + * @since 2.9 + * @var string extra HTML to include in the history box of the question display, + * if it is shown. + */ + public $extrahistorycontent = ''; + /** * If not empty, then a link to edit the question will be included in * the info box for the question. diff --git a/question/engine/renderer.php b/question/engine/renderer.php index 4d781e52c7d..ce87306ef05 100644 --- a/question/engine/renderer.php +++ b/question/engine/renderer.php @@ -144,6 +144,7 @@ class core_question_renderer extends plugin_renderer_base { $output .= $this->number($number); $output .= $this->status($qa, $behaviouroutput, $options); $output .= $this->mark_summary($qa, $behaviouroutput, $options); + $output .= $options->extrainfocontent; $output .= $this->question_flag($qa, $options->flags); $output .= $this->edit_question_link($qa, $options); return $output; @@ -485,8 +486,10 @@ class core_question_renderer extends plugin_renderer_base { } return html_writer::tag('h4', get_string('responsehistory', 'question'), - array('class' => 'responsehistoryheader')) . html_writer::tag('div', - html_writer::table($table, true), array('class' => 'responsehistoryheader')); + array('class' => 'responsehistoryheader')) . + $options->extrahistorycontent . + html_writer::tag('div', html_writer::table($table, true), + array('class' => 'responsehistoryheader')); } } diff --git a/question/engine/upgrade.txt b/question/engine/upgrade.txt index 07cc42376ff..86f1529cd41 100644 --- a/question/engine/upgrade.txt +++ b/question/engine/upgrade.txt @@ -29,6 +29,11 @@ This files describes API changes for the core question engine. To see examples of where these are used, look at the chagnes from MDL-40992. +2) New fields in question_display_options, ->extrainfocontent and ->extrahistorycontent. + These default to blank, but can be used to inject extra content into those parts + of the question display. If you have overridden the methods in + core_question_renderer that use these fields, you may need to update your renderer. + === 2.6 ===