Merge branch 'MDL-40992' of git://github.com/timhunt/moodle
authorDavid Monllao <davidm@moodle.com>
Wed, 1 Apr 2015 00:00:43 +0000 (08:00 +0800)
committerDavid Monllao <davidm@moodle.com>
Wed, 1 Apr 2015 00:00:43 +0000 (08:00 +0800)
41 files changed:
lang/en/question.php
mod/quiz/attemptlib.php
mod/quiz/backup/moodle2/backup_quiz_stepslib.php
mod/quiz/db/install.xml
mod/quiz/db/upgrade.php
mod/quiz/lang/en/quiz.php
mod/quiz/mod_form.php
mod/quiz/processattempt.php
mod/quiz/renderer.php
mod/quiz/settings.php
mod/quiz/styles.css
mod/quiz/tests/behat/attempt_basic.feature [moved from mod/quiz/tests/behat/attempt.feature with 100% similarity]
mod/quiz/tests/behat/attempt_redo_questions.feature [new file with mode: 0644]
mod/quiz/upgrade.txt
mod/quiz/version.php
question/behaviour/adaptive/tests/behaviourtype_test.php
question/behaviour/behaviourtypebase.php
question/behaviour/deferredcbm/tests/behaviourtype_test.php
question/behaviour/deferredfeedback/tests/behaviourtype_test.php
question/behaviour/immediatecbm/behaviourtype.php
question/behaviour/immediatecbm/tests/behaviourtype_test.php
question/behaviour/immediatefeedback/behaviourtype.php
question/behaviour/immediatefeedback/tests/behaviourtype_test.php
question/behaviour/informationitem/tests/behaviourtype_test.php
question/behaviour/interactive/behaviourtype.php
question/behaviour/interactive/tests/behaviourtype_test.php
question/behaviour/interactivecountback/tests/behaviourtype_test.php
question/behaviour/manualgraded/tests/behaviourtype_test.php
question/behaviour/missing/tests/behaviourtype_test.php
question/behaviour/upgrade.txt
question/engine/datalib.php
question/engine/lib.php
question/engine/questionattempt.php
question/engine/questionattemptstep.php
question/engine/questionusage.php
question/engine/renderer.php
question/engine/tests/helpers.php
question/engine/tests/questionengine_test.php
question/engine/tests/unitofwork_test.php
question/engine/upgrade.txt
question/upgrade.txt

index 0e7927c..10f7c99 100644 (file)
@@ -398,7 +398,6 @@ $string['requiresgrading'] = 'Requires grading';
 $string['responsehistory'] = 'Response history';
 $string['restart'] = 'Start again';
 $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}';
@@ -436,6 +435,7 @@ $string['unknownquestion'] = 'Unknown question: {$a}.';
 $string['unknownquestioncatregory'] = 'Unknown question category: {$a}.';
 $string['unknownquestiontype'] = 'Unknown question type: {$a}.';
 $string['unusedcategorydeleted'] = 'This category has been deleted because, after deleting the course, its questions weren\'t used any more.';
+$string['updatedisplayoptions'] = 'Update display options';
 $string['whethercorrect'] = 'Whether correct';
 $string['whethercorrect_help'] = 'This covers both the textual description \'Correct\', \'Partially correct\' or \'Incorrect\', and any coloured highlighting that conveys the same information.';
 $string['whichtries'] = 'Which tries';
index 91ac3d2..aeb882f 100644 (file)
@@ -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 ==================================================
 
     /**
@@ -1527,6 +1688,57 @@ class quiz_attempt {
         $transaction->allow_commit();
     }
 
+    /**
+     * 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_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();
+
+        $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();
+    }
+
     /**
      * Process all the autosaved data that was part of the current request.
      *
index a15bb81..79fe698 100644 (file)
@@ -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',
index cf2207f..21bd119 100644 (file)
@@ -17,6 +17,7 @@
         <FIELD NAME="overduehandling" TYPE="char" LENGTH="16" NOTNULL="true" DEFAULT="autoabandon" SEQUENCE="false" COMMENT="The method used to handle overdue attempts. 'autosubmit', 'graceperiod' or 'autoabandon'."/>
         <FIELD NAME="graceperiod" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The amount of time (in seconds) after the time limit runs out during which attempts can still be submitted, if overduehandling is set to allow it."/>
         <FIELD NAME="preferredbehaviour" TYPE="char" LENGTH="32" NOTNULL="true" SEQUENCE="false" COMMENT="The behaviour to ask questions to use."/>
+        <FIELD NAME="canredoquestions" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Allows students to redo any completed question within a quiz attempt."/>
         <FIELD NAME="attempts" TYPE="int" LENGTH="6" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The maximum number of attempts a student is allowed."/>
         <FIELD NAME="attemptonlast" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether subsequent attempts start from teh answer to the previous attempt (1) or start blank (0)."/>
         <FIELD NAME="grademethod" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="One of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST."/>
index acbdbf0..c9b5035 100644 (file)
@@ -821,5 +821,19 @@ function xmldb_quiz_upgrade($oldversion) {
         upgrade_mod_savepoint(true, 2015030500, 'quiz');
     }
 
+    if ($oldversion < 2015030900) {
+        // Define field canredoquestions to be added to quiz.
+        $table = new xmldb_table('quiz');
+        $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)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Quiz savepoint reached.
+        upgrade_mod_savepoint(true, 2015030900, 'quiz');
+    }
+
     return true;
 }
index 1769984..60575e3 100644 (file)
@@ -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';
@@ -448,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}';
@@ -688,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';
@@ -752,8 +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['reviewnever'] = 'Never allow review';
 $string['reviewofattempt'] = 'Review of attempt {$a}';
 $string['reviewofpreview'] = 'Review of preview';
index 694ab98..2cbd1f1 100644 (file)
@@ -205,6 +205,18 @@ class mod_quiz_mod_form extends moodleform_mod {
         $mform->addHelpButton('preferredbehaviour', 'howquestionsbehave', 'question');
         $mform->setDefault('preferredbehaviour', $quizconfig->preferredbehaviour);
 
+        // 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) {
+            if (!question_engine::can_questions_finish_during_the_attempt($behaviour)) {
+                $mform->disabledIf('canredoquestions', 'preferredbehaviour', 'eq', $behaviour);
+            }
+        }
+
         // Each attempt builds on last.
         $mform->addElement('selectyesno', 'attemptonlast',
                 get_string('eachattemptbuildsonthelast', 'quiz'));
index edf6d8c..1b5070e 100644 (file)
@@ -144,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);
index 9b74671..89832c5 100644 (file)
@@ -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,8 +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->render_question($slot, false, $this,
+                    $attemptobj->attempt_url($slot, $page), $this);
         }
 
         $output .= html_writer::start_tag('div', array('class' => 'submitbtns'));
@@ -486,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');
@@ -497,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.
@@ -1184,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();
 }
index fc4a28f..6360217 100644 (file)
@@ -128,6 +128,12 @@ if ($ADMIN->fulltree) {
             get_string('howquestionsbehave', 'question'), get_string('howquestionsbehave_desc', 'quiz'),
             'deferredfeedback'));
 
+    // 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',
             get_string('eachattemptbuildsonthelast', 'quiz'),
index 9c8b879..b490d49 100644 (file)
     text-align: right;
 }
 
+.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,
 #page-mod-quiz-review .mod_quiz-blocked_question_warning .que .formulation {
     background: #eee;
diff --git a/mod/quiz/tests/behat/attempt_redo_questions.feature b/mod/quiz/tests/behat/attempt_redo_questions.feature
new file mode 100644 (file)
index 0000000..ec38b60
--- /dev/null
@@ -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"
index 5db9d21..5784280 100644 (file)
@@ -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 ===
 
index d9ddae5..e0cd1fd 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2015030500;
+$plugin->version   = 2015030900;
 $plugin->requires  = 2014110400;
 $plugin->component = 'mod_quiz';
 $plugin->cron      = 60;
index 4b44ac7..9071ae5 100644 (file)
@@ -56,6 +56,10 @@ class qbehaviour_adaptive_type_test extends basic_testcase {
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index 8cc0c7d..c5ceb9f 100644 (file)
@@ -59,6 +59,15 @@ abstract class question_behaviour_type {
         return array();
     }
 
+    /**
+     * With this behaviour, is it possible that a question might finish as the student
+     * interacts with it, without a call to the {@link question_attempt::finish()} method?
+     * @return bool whether with this behaviour, questions may finish naturally.
+     */
+    public function can_questions_finish_during_the_attempt() {
+        return false;
+    }
+
     /**
      * Adjust a random guess score for a question using this model. You have to
      * do this without knowing details of the specific question, or which usage
index d0f44c0..addcf4b 100644 (file)
@@ -57,6 +57,10 @@ class qbehaviour_deferredcbm_type_test extends qbehaviour_walkthrough_test_base
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index 8522505..6b63e22 100644 (file)
@@ -51,6 +51,10 @@ class qbehaviour_deferredfeedback_type_test extends qbehaviour_walkthrough_test_
         $this->assertTrue($this->behaviourtype->is_archetypal());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_get_unused_display_options() {
         $this->assertEquals(array('correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'),
                 $this->behaviourtype->get_unused_display_options());
index 01a2ec3..1e8a7ef 100644 (file)
@@ -39,4 +39,8 @@ class qbehaviour_immediatecbm_type extends qbehaviour_deferredcbm_type {
     public function get_unused_display_options() {
         return array();
     }
+
+    public function can_questions_finish_during_the_attempt() {
+        return true;
+    }
 }
index 009bf12..f292b06 100644 (file)
@@ -56,6 +56,10 @@ class qbehaviour_immediatecbm_type_test extends qbehaviour_walkthrough_test_base
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertTrue($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index c366fe3..7e06530 100644 (file)
@@ -36,4 +36,8 @@ class qbehaviour_immediatefeedback_type extends question_behaviour_type {
     public function is_archetypal() {
         return true;
     }
+
+    public function can_questions_finish_during_the_attempt() {
+        return true;
+    }
 }
index cdbf324..f78ff93 100644 (file)
@@ -56,6 +56,10 @@ class qbehaviour_immediatefeedback_type_test extends basic_testcase {
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertTrue($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index 965ebf4..7bb644b 100644 (file)
@@ -56,6 +56,10 @@ class qbehaviour_informationitem_type_testcase extends basic_testcase {
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index 3b44358..b57bef8 100644 (file)
@@ -40,4 +40,8 @@ class qbehaviour_interactive_type extends question_behaviour_type {
     public function allows_multiple_submitted_responses() {
         return true;
     }
+
+    public function can_questions_finish_during_the_attempt() {
+        return true;
+    }
 }
index 353dfe0..ad1f672 100644 (file)
@@ -56,6 +56,10 @@ class qbehaviour_interactive_type_test extends basic_testcase {
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertTrue($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index 7b937e2..992605b 100644 (file)
@@ -56,6 +56,10 @@ class qbehaviour_interactivecountback_type_test extends basic_testcase {
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertTrue($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index 9957a8a..e5718dd 100644 (file)
@@ -56,6 +56,10 @@ class qbehaviour_manualgraded_type_test extends basic_testcase {
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index fe18cae..ad1482e 100644 (file)
@@ -56,6 +56,10 @@ class qbehaviour_missing_type_test extends basic_testcase {
                 $this->behaviourtype->get_unused_display_options());
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertFalse($this->behaviourtype->can_questions_finish_during_the_attempt());
+    }
+
     public function test_adjust_random_guess_score() {
         $this->assertEquals(0, $this->behaviourtype->adjust_random_guess_score(0));
         $this->assertEquals(1, $this->behaviourtype->adjust_random_guess_score(1));
index 1b496d8..5c530cd 100644 (file)
@@ -2,10 +2,16 @@ This files describes API changes for question behaviour plugins.
 
 === 2.9 ===
 
-1) New method question_behaviour::can_finish_during_attempt. This returns false
-   by default. You should override it if, with your behaviour, questions may
+1) There are new methods question_behaviour::can_finish_during_attempt and
+   question_behaviour_type::can_finish_during_attempt. These methods both return
+   false by default. You should override it if, with your behaviour, questions may
    finish just through the student interacting with them (e.g. by clicking the
-   Check button within the question.)
+   Check button within the question).
+
+   The behaviour type method answers the the question for this behaviour in
+   general, without reference to a specific question. The method on the
+   behaviour class answers the question for a specific attempt at a specific
+   question.
 
 
 === 2.7 ===
index 9574743..55be766 100644 (file)
@@ -252,6 +252,48 @@ class question_engine_data_mapper {
         return $this->prepare_step_data($step, $record->id, $context);
     }
 
+    /**
+     * Store new metadata for an existing {@link question_attempt} in the database.
+     *
+     * Private method, only for use by other parts of the question engine.
+     *
+     * @param question_attempt $qa the question attempt to store meta data for.
+     * @param array $names the names of the metadata variables to store.
+     * @return array of question_attempt_step_data rows, that still need to be inserted.
+     */
+    public function insert_question_attempt_metadata(question_attempt $qa, array $names) {
+        $firststep = $qa->get_step(0);
+
+        $rows = array();
+        foreach ($names as $name) {
+            $data = new stdClass();
+            $data->attemptstepid = $firststep->get_id();
+            $data->name = ':_' . $name;
+            $data->value = $firststep->get_metadata_var($name);
+            $rows[] = $data;
+        }
+
+        return $rows;
+    }
+
+    /**
+     * Updates existing metadata for an existing {@link question_attempt} in the database.
+     *
+     * Private method, only for use by other parts of the question engine.
+     *
+     * @param question_attempt $qa the question attempt to store meta data for.
+     * @param array $names the names of the metadata variables to store.
+     * @return array of question_attempt_step_data rows, that still need to be inserted.
+     */
+    public function update_question_attempt_metadata(question_attempt $qa, array $names) {
+        global $DB;
+        list($condition, $params) = $DB->get_in_or_equal($names);
+        $params[] = $qa->get_step(0)->get_id();
+        $DB->delete_records_select('question_attempt_step_data',
+                'name ' . $condition . ' AND attemptstepid = ?', $params);
+        return $this->insert_question_attempt_metadata($qa, $names);
+    }
+
     /**
      * Load a {@link question_attempt_step} from the database.
      *
@@ -867,6 +909,7 @@ ORDER BY
     public function update_question_attempt(question_attempt $qa) {
         $record = new stdClass();
         $record->id = $qa->get_database_id();
+        $record->slot = $qa->get_slot();
         $record->variant = $qa->get_variant();
         $record->maxmark = $qa->get_max_mark();
         $record->minfraction = $qa->get_min_fraction();
@@ -1246,15 +1289,15 @@ class question_engine_unit_of_work implements question_usage_observer {
 
     /**
      * @var array list of slot => {@link question_attempt}s that
-     * were already in the usage, and which have been modified.
+     * have been added to the usage.
      */
-    protected $attemptsmodified = array();
+    protected $attemptsadded = array();
 
     /**
      * @var array list of slot => {@link question_attempt}s that
-     * have been added to the usage.
+     * were already in the usage, and which have been modified.
      */
-    protected $attemptsadded = array();
+    protected $attemptsmodified = array();
 
     /**
      * @var array of array(question_attempt_step, question_attempt id, seq number)
@@ -1274,6 +1317,16 @@ class question_engine_unit_of_work implements question_usage_observer {
      */
     protected $stepsdeleted = array();
 
+    /**
+     * @var array int slot => string name => question_attempt.
+     */
+    protected $metadataadded = array();
+
+    /**
+     * @var array int slot => string name => question_attempt.
+     */
+    protected $metadatamodified = array();
+
     /**
      * Constructor.
      * @param question_usage_by_activity $quba the usage to track.
@@ -1286,6 +1339,10 @@ class question_engine_unit_of_work implements question_usage_observer {
         $this->modified = true;
     }
 
+    public function notify_attempt_added(question_attempt $qa) {
+        $this->attemptsadded[$qa->get_slot()] = $qa;
+    }
+
     public function notify_attempt_modified(question_attempt $qa) {
         $slot = $qa->get_slot();
         if (!array_key_exists($slot, $this->attemptsadded)) {
@@ -1293,8 +1350,28 @@ class question_engine_unit_of_work implements question_usage_observer {
         }
     }
 
-    public function notify_attempt_added(question_attempt $qa) {
-        $this->attemptsadded[$qa->get_slot()] = $qa;
+    public function notify_attempt_moved(question_attempt $qa, $oldslot) {
+        $newslot = $qa->get_slot();
+
+        if (array_key_exists($oldslot, $this->attemptsadded)) {
+            unset($this->attemptsadded[$oldslot]);
+            $this->attemptsadded[$newslot] = $qa;
+            return;
+        }
+
+        if (array_key_exists($oldslot, $this->attemptsmodified)) {
+            unset($this->attemptsmodified[$oldslot]);
+        }
+        $this->attemptsmodified[$newslot] = $qa;
+
+        if (array_key_exists($oldslot, $this->metadataadded)) {
+            $this->metadataadded[$newslot] = $this->metadataadded[$oldslot];
+            unset($this->metadataadded[$oldslot]);
+        }
+        if (array_key_exists($oldslot, $this->metadatamodified)) {
+            $this->metadatamodified[$newslot] = $this->metadatamodified[$oldslot];
+            unset($this->metadatamodified[$oldslot]);
+        }
     }
 
     public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
@@ -1371,6 +1448,42 @@ class question_engine_unit_of_work implements question_usage_observer {
         $this->stepsdeleted[$stepid] = $step;
     }
 
+    public function notify_metadata_added(question_attempt $qa, $name) {
+        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
+            return;
+        }
+
+        if ($this->is_step_added($qa->get_step(0)) !== false) {
+            return;
+        }
+
+        if (isset($this->metadataadded[$qa->get_slot()][$name])) {
+            return;
+        }
+
+        $this->metadataadded[$qa->get_slot()][$name] = $qa;
+    }
+
+    public function notify_metadata_modified(question_attempt $qa, $name) {
+        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
+            return;
+        }
+
+        if ($this->is_step_added($qa->get_step(0)) !== false) {
+            return;
+        }
+
+        if (isset($this->metadataadded[$qa->get_slot()][$name])) {
+            return;
+        }
+
+        if (isset($this->metadatamodified[$qa->get_slot()][$name])) {
+            return;
+        }
+
+        $this->metadatamodified[$qa->get_slot()][$name] = $qa;
+    }
+
     /**
      * @param question_attempt_step $step a step
      * @return int|false if the step is in the list of steps to be added, return
@@ -1437,23 +1550,40 @@ class question_engine_unit_of_work implements question_usage_observer {
                     $step, $questionattemptid, $seq, $this->quba->get_owning_context());
         }
 
+        foreach ($this->attemptsmodified as $qa) {
+            $dm->update_question_attempt($qa);
+        }
+
         foreach ($this->attemptsadded as $qa) {
             $stepdata[] = $dm->insert_question_attempt(
                     $qa, $this->quba->get_owning_context());
         }
 
-        foreach ($this->attemptsmodified as $qa) {
-            $dm->update_question_attempt($qa);
+        foreach ($this->metadataadded as $info) {
+            $qa = reset($info);
+            $stepdata[] = $dm->insert_question_attempt_metadata($qa, array_keys($info));
+        }
+
+        foreach ($this->metadatamodified as $info) {
+            $qa = reset($info);
+            $stepdata[] = $dm->update_question_attempt_metadata($qa, array_keys($info));
         }
 
         if ($this->modified) {
             $dm->update_questions_usage_by_activity($this->quba);
         }
 
-        if (!$stepdata) {
-            return;
+        if ($stepdata) {
+            $dm->insert_all_step_data(call_user_func_array('array_merge', $stepdata));
         }
-        $dm->insert_all_step_data(call_user_func_array('array_merge', $stepdata));
+
+        $this->stepsdeleted = array();
+        $this->stepsmodified = array();
+        $this->stepsadded = array();
+        $this->attemptsdeleted = array();
+        $this->attemptsadded = array();
+        $this->attemptsmodified = array();
+        $this->modified = false;
     }
 }
 
@@ -1598,9 +1728,9 @@ class question_file_loader implements question_response_files {
     protected $name;
 
     /**
-    * @var string the value to stored in the question_attempt_step_data to
+     * @var string the value to stored in the question_attempt_step_data to
      * represent these files.
-    */
+     */
     protected $value;
 
     /** @var int the context id that the files belong to. */
index 2ff5fd3..516e320 100644 (file)
@@ -200,6 +200,16 @@ abstract class question_engine {
         return self::get_behaviour_type($behaviour)->get_unused_display_options();
     }
 
+    /**
+     * With this behaviour, is it possible that a question might finish as the student
+     * interacts with it, without a call to the {@link question_attempt::finish()} method?
+     * @param string $behaviour the name of a behaviour. E.g. 'deferredfeedback'.
+     * @return bool whether with this behaviour, questions may finish naturally.
+     */
+    public static function can_questions_finish_during_the_attempt($behaviour) {
+        return self::get_behaviour_type($behaviour)->can_questions_finish_during_the_attempt();
+    }
+
     /**
      * Create a behaviour for a particular type. If that type cannot be
      * found, return an instance of qbehaviour_missing.
@@ -581,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.
index 8a43d90..bff43e8 100644 (file)
@@ -362,7 +362,7 @@ class question_attempt {
     /**
      * Get one of the steps in this attempt.
      *
-     * @param int $i the step number.
+     * @param int $i the step number, which counts from 0.
      * @return question_attempt_step
      */
     public function get_step($i) {
@@ -748,6 +748,30 @@ class question_attempt {
         return $this->behaviour->summarise_action($step);
     }
 
+    /**
+     * Return one of the bits of metadata for a this question attempt.
+     * @param string $name the name of the metadata variable to return.
+     * @return string the value of that metadata variable.
+     */
+    public function get_metadata($name) {
+        return $this->get_step(0)->get_metadata_var($name);
+    }
+
+    /**
+     * Set some metadata for this question attempt.
+     * @param string $name the name of the metadata variable to return.
+     * @param string $value the value to set that metadata variable to.
+     */
+    public function set_metadata($name, $value) {
+        $firststep = $this->get_step(0);
+        if (!$firststep->has_metadata_var($name)) {
+            $this->observer->notify_metadata_added($this, $name);
+        } else if ($value !== $firststep->get_metadata_var($name)) {
+            $this->observer->notify_metadata_modified($this, $name);
+        }
+        $firststep->set_metadata_var($name, $value);
+    }
+
     /**
      * Helper function used by {@link rewrite_pluginfile_urls()} and
      * {@link rewrite_response_pluginfile_urls()}.
@@ -931,6 +955,10 @@ class question_attempt {
     public function start($preferredbehaviour, $variant, $submitteddata = array(),
             $timestamp = null, $userid = null, $existingstepid = null) {
 
+        if ($this->get_num_steps() > 0) {
+            throw new coding_exception('Cannot start a question that is already started.');
+        }
+
         // Initialise the behaviour.
         $this->variant = $variant;
         if (is_string($preferredbehaviour)) {
@@ -1266,6 +1294,15 @@ class question_attempt {
         }
     }
 
+    /**
+     * Change the max mark for this question_attempt.
+     * @param float $maxmark the new max mark.
+     */
+    public function set_max_mark($maxmark) {
+        $this->maxmark = $maxmark;
+        $this->observer->notify_attempt_modified($this);
+    }
+
     /**
      * Perform a manual grading action on this attempt.
      * @param string $comment the comment being added.
index 101a37f..c0bb6fd 100644 (file)
@@ -370,6 +370,48 @@ class question_attempt_step {
         return $this->data;
     }
 
+    /**
+     * Set a metadata variable.
+     *
+     * Do not call this method directly from  your code. It is for internal
+     * use only. You should call {@link question_usage::set_question_attempt_metadata()}.
+     *
+     * @param string $name the name of the variable to set. [a-z][a-z0-9]*.
+     * @param string $value the value to set.
+     */
+    public function set_metadata_var($name, $value) {
+        $this->data[':_' . $name] = $value;
+    }
+
+    /**
+     * Whether this step has a metadata variable.
+     *
+     * Do not call this method directly from  your code. It is for internal
+     * use only. You should call {@link question_usage::get_question_attempt_metadata()}.
+     *
+     * @param string $name the name of the variable to set. [a-z][a-z0-9]*.
+     * @return bool the value to set previously, or null if this variable was never set.
+     */
+    public function has_metadata_var($name) {
+        return isset($this->data[':_' . $name]);
+    }
+
+    /**
+     * Get a metadata variable.
+     *
+     * Do not call this method directly from  your code. It is for internal
+     * use only. You should call {@link question_usage::get_question_attempt_metadata()}.
+     *
+     * @param string $name the name of the variable to set. [a-z][a-z0-9]*.
+     * @return string the value to set previously, or null if this variable was never set.
+     */
+    public function get_metadata_var($name) {
+        if (!$this->has_metadata_var($name)) {
+            return null;
+        }
+        return $this->data[':_' . $name];
+    }
+
     /**
      * Create a question_attempt_step from records loaded from the database.
      * @param Iterator $records Raw records loaded from the database.
index 536c880..49c0ef5 100644 (file)
@@ -172,6 +172,42 @@ class question_usage_by_activity {
         return $qa->get_slot();
     }
 
+    /**
+     * Add another question to this usage, in the place of an existing slot.
+     * The question_attempt that was in that slot is moved to the end at a new
+     * slot number, which is returned.
+     *
+     * The added question is not started until you call {@link start_question()}
+     * on it.
+     *
+     * @param int $slot the slot-number of the question to replace.
+     * @param question_definition $question the question to add.
+     * @param number $maxmark the maximum this question will be marked out of in
+     *      this attempt (optional). If not given, the max mark from the $qa we
+     *      are replacing is used.
+     * @return int the new slot number of the question that was displaced.
+     */
+    public function add_question_in_place_of_other($slot, question_definition $question, $maxmark = null) {
+        $newslot = $this->next_slot_number();
+
+        $oldqa = $this->get_question_attempt($slot);
+        $oldqa->set_slot($newslot);
+        $this->questionattempts[$newslot] = $oldqa;
+
+        if ($maxmark === null) {
+            $maxmark = $oldqa->get_max_mark();
+        }
+
+        $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark);
+        $qa->set_slot($slot);
+        $this->questionattempts[$slot] = $qa;
+
+        $this->observer->notify_attempt_moved($oldqa, $slot);
+        $this->observer->notify_attempt_added($qa);
+
+        return $newslot;
+    }
+
     /**
      * The slot number that will be allotted to the next question added.
      */
@@ -377,6 +413,27 @@ class question_usage_by_activity {
         return $this->get_question_attempt($slot)->get_right_answer_summary();
     }
 
+    /**
+     * Return one of the bits of metadata for a particular question attempt in
+     * this usage.
+     * @param int $slot the slot number of the question of inereest.
+     * @param string $name the name of the metadata variable to return.
+     * @return string the value of that metadata variable.
+     */
+    public function get_question_attempt_metadata($slot, $name) {
+        return $this->get_question_attempt($slot)->get_metadata($name);
+    }
+
+    /**
+     * Set some metadata for a particular question attempt in this usage.
+     * @param int $slot the slot number of the question of inerest.
+     * @param string $name the name of the metadata variable to return.
+     * @param string $value the value to set that metadata variable to.
+     */
+    public function set_question_attempt_metadata($slot, $name, $value) {
+        $this->get_question_attempt($slot)->set_metadata($name, $value);
+    }
+
     /**
      * Get the {@link core_question_renderer}, in collaboration with appropriate
      * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the
@@ -833,6 +890,15 @@ class question_usage_by_activity {
         }
     }
 
+    /**
+     * Change the max mark for this question_attempt.
+     * @param int $slot the slot number of the question of inerest.
+     * @param float $maxmark the new max mark.
+     */
+    public function set_max_mark($slot, $maxmark) {
+        $this->get_question_attempt($slot)->set_max_mark($maxmark);
+    }
+
     /**
      * Create a question_usage_by_activity from records loaded from the database.
      *
@@ -967,6 +1033,12 @@ interface question_usage_observer {
     /** Called when a field of the question_usage_by_activity is changed. */
     public function notify_modified();
 
+    /**
+     * Called when a new question attempt is added to this usage.
+     * @param question_attempt $qa the newly added question attempt.
+     */
+    public function notify_attempt_added(question_attempt $qa);
+
     /**
      * Called when the fields of a question attempt in this usage are modified.
      * @param question_attempt $qa the newly added question attempt.
@@ -974,10 +1046,11 @@ interface question_usage_observer {
     public function notify_attempt_modified(question_attempt $qa);
 
     /**
-     * Called when a new question attempt is added to this usage.
-     * @param question_attempt $qa the newly added question attempt.
+     * Called when a question_attempt has been moved to a new slot.
+     * @param question_attempt $qa The question attempt that was moved.
+     * @param int $oldslot The previous slot number of that attempt.
      */
-    public function notify_attempt_added(question_attempt $qa);
+    public function notify_attempt_moved(question_attempt $qa, $oldslot);
 
     /**
      * Called when a new step is added to a question attempt in this usage.
@@ -1002,6 +1075,19 @@ interface question_usage_observer {
      */
     public function notify_step_deleted(question_attempt_step $step, question_attempt $qa);
 
+    /**
+     * Called when a new metadata variable is set on a question attempt in this usage.
+     * @param question_attempt $qa the question attempt the metadata is being added to.
+     * @param int $name the name of the metadata variable added.
+     */
+    public function notify_metadata_added(question_attempt $qa, $name);
+
+    /**
+     * Called when a metadata variable on a question attempt in this usage is updated.
+     * @param question_attempt $qa the question attempt where the metadata is being modified.
+     * @param int $name the name of the metadata variable modified.
+     */
+    public function notify_metadata_modified(question_attempt $qa, $name);
 }
 
 
@@ -1015,9 +1101,11 @@ interface question_usage_observer {
 class question_usage_null_observer implements question_usage_observer {
     public function notify_modified() {
     }
+    public function notify_attempt_added(question_attempt $qa) {
+    }
     public function notify_attempt_modified(question_attempt $qa) {
     }
-    public function notify_attempt_added(question_attempt $qa) {
+    public function notify_attempt_moved(question_attempt $qa, $oldslot) {
     }
     public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
     }
@@ -1025,4 +1113,8 @@ class question_usage_null_observer implements question_usage_observer {
     }
     public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
     }
+    public function notify_metadata_added(question_attempt $qa, $name) {
+    }
+    public function notify_metadata_modified(question_attempt $qa, $name) {
+    }
 }
index 4d781e5..ce87306 100644 (file)
@@ -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'));
     }
 
 }
index f596ab0..c472e1e 100644 (file)
@@ -84,6 +84,14 @@ class testable_question_engine_unit_of_work extends question_engine_unit_of_work
     public function get_steps_deleted() {
         return $this->stepsdeleted;
     }
+
+    public function get_metadata_added() {
+        return $this->metadataadded;
+    }
+
+    public function get_metadata_modified() {
+        return $this->metadatamodified;
+    }
 }
 
 
index 1a5505d..4f63d58 100644 (file)
@@ -62,6 +62,11 @@ class question_engine_test extends advanced_testcase {
                 question_engine::get_behaviour_unused_display_options('manualgraded'));
     }
 
+    public function test_can_questions_finish_during_the_attempt() {
+        $this->assertFalse(question_engine::can_questions_finish_during_the_attempt('deferredfeedback'));
+        $this->assertTrue(question_engine::can_questions_finish_during_the_attempt('interactive'));
+    }
+
     public function test_sort_behaviours() {
         $in = array('b1' => 'Behave 1', 'b2' => 'Behave 2', 'b3' => 'Behave 3', 'b4' => 'Behave 4', 'b5' => 'Behave 5', 'b6' => 'Behave 6');
 
index 17cec0d..873b857 100644 (file)
@@ -91,8 +91,8 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
         array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'todo',             null, 1256233720, 1, '-submit',     1),
         array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'todo',             null, 1256233720, 1, '-_triesleft', 1),
         array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 3, 2, 'todo',             null, 1256233740, 1, '-tryagain',   1),
-        array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 3, 'gradedright',      null, 1256233790, 1, 'answer',     'frog'),
-        array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 3, 'gradedright', 1.0000000, 1256233790, 1, '-submit',     1),
+        array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 3, 'gradedright', 0.6666667, 1256233790, 1, 'answer',     'frog'),
+        array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 3, 'gradedright', 0.6666667, 1256233790, 1, '-submit',     1),
         );
     }
 
@@ -103,6 +103,8 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
         $this->assertEquals(0, count($this->observer->get_steps_added()));
         $this->assertEquals(0, count($this->observer->get_steps_modified()));
         $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
     }
 
     public function test_update_usage() {
@@ -120,6 +122,9 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
         $this->assertEquals(1, count($newattempts));
         $this->assertTrue($this->quba->get_question_attempt($slot) === reset($newattempts));
         $this->assertSame($slot, key($newattempts));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
     }
 
     public function test_add_and_start_question() {
@@ -136,6 +141,9 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
         $this->assertTrue($this->quba->get_question_attempt($slot) === reset($newattempts));
         $this->assertSame($slot, key($newattempts));
         $this->assertEquals(0, count($this->observer->get_steps_added()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
     }
 
     public function test_process_action() {
@@ -157,6 +165,9 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
 
         list($newstep, $qaid, $seq) = reset($newsteps);
         $this->assertSame($this->quba->get_question_attempt($this->slot)->get_last_step(), $newstep);
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
     }
 
     public function test_regrade_same_steps() {
@@ -184,6 +195,9 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
             $this->assertSame(array($step, $updatedattempt->get_database_id(), $seq),
                     $updatedsteps[$seq]);
         }
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
     }
 
     public function test_regrade_losing_steps() {
@@ -220,6 +234,9 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
         $seconddeletedstep = end($deletedsteps);
         $this->assertEquals(array('answer' => 'frog', '-submit' => 1),
                 $seconddeletedstep->get_all_data());
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
     }
 
     public function test_tricky_regrade() {
@@ -258,5 +275,237 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
         }
 
         $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_move_question() {
+
+        $q = test_question_maker::make_question('truefalse');
+        $newslot = $this->quba->add_question_in_place_of_other($this->slot, $q);
+        $this->quba->start_question($this->slot);
+
+        $addedattempts = $this->observer->get_attempts_added();
+        $this->assertEquals(1, count($addedattempts));
+        $addedattempt = reset($addedattempts);
+        $this->assertSame($this->quba->get_question_attempt($this->slot), $addedattempt);
+
+        $updatedattempts = $this->observer->get_attempts_modified();
+        $this->assertEquals(1, count($updatedattempts));
+        $updatedattempt = reset($updatedattempts);
+        $this->assertSame($this->quba->get_question_attempt($newslot), $updatedattempt);
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_move_question_then_modify() {
+
+        $q = test_question_maker::make_question('truefalse');
+        $newslot = $this->quba->add_question_in_place_of_other($this->slot, $q);
+        $this->quba->start_question($this->slot);
+        $this->quba->process_action($this->slot, array('answer' => 'frog', '-submit' => 1));
+        $this->quba->manual_grade($newslot, 'Test', 0.5, FORMAT_HTML);
+
+        $addedattempts = $this->observer->get_attempts_added();
+        $this->assertEquals(1, count($addedattempts));
+        $addedattempt = reset($addedattempts);
+        $this->assertSame($this->quba->get_question_attempt($this->slot), $addedattempt);
+
+        $updatedattempts = $this->observer->get_attempts_modified();
+        $this->assertEquals(1, count($updatedattempts));
+        $updatedattempt = reset($updatedattempts);
+        $this->assertSame($this->quba->get_question_attempt($newslot), $updatedattempt);
+
+        $newsteps = $this->observer->get_steps_added();
+        $this->assertEquals(1, count($newsteps));
+        list($newstep, $qaid, $seq) = reset($newsteps);
+        $this->assertSame($this->quba->get_question_attempt($newslot)->get_last_step(), $newstep);
+
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_move_question_then_move_again() {
+        $originalqa = $this->quba->get_question_attempt($this->slot);
+
+        $q1 = test_question_maker::make_question('truefalse');
+        $newslot = $this->quba->add_question_in_place_of_other($this->slot, $q1);
+        $this->quba->start_question($this->slot);
+
+        $q2 = test_question_maker::make_question('truefalse');
+        $newslot2 = $this->quba->add_question_in_place_of_other($newslot, $q2);
+        $this->quba->start_question($newslot);
+
+        $addedattempts = $this->observer->get_attempts_added();
+        $this->assertEquals(2, count($addedattempts));
+
+        $updatedattempts = $this->observer->get_attempts_modified();
+        $this->assertEquals(1, count($updatedattempts));
+        $updatedattempt = reset($updatedattempts);
+        $this->assertSame($originalqa, $updatedattempt);
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_set_max_mark() {
+        $this->quba->set_max_mark($this->slot, 6.0);
+        $this->assertEquals(4.0, $this->quba->get_total_mark(), '', 0.0000005);
+
+        $this->assertEquals(0, count($this->observer->get_attempts_added()));
+
+        $updatedattempts = $this->observer->get_attempts_modified();
+        $this->assertEquals(1, count($updatedattempts));
+        $updatedattempt = reset($updatedattempts);
+        $this->assertSame($this->quba->get_question_attempt($this->slot), $updatedattempt);
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_set_question_attempt_metadata() {
+        $this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
+        $this->assertEquals('a value', $this->quba->get_question_attempt_metadata($this->slot, 'metathingy'));
+
+        $this->assertEquals(0, count($this->observer->get_attempts_added()));
+        $this->assertEquals(0, count($this->observer->get_attempts_modified()));
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(array($this->slot => array('metathingy' => $this->quba->get_question_attempt($this->slot))),
+                $this->observer->get_metadata_added());
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_set_question_attempt_metadata_then_change() {
+        $this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
+        $this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'different value');
+        $this->assertEquals('different value', $this->quba->get_question_attempt_metadata($this->slot, 'metathingy'));
+
+        $this->assertEquals(0, count($this->observer->get_attempts_added()));
+        $this->assertEquals(0, count($this->observer->get_attempts_modified()));
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(array($this->slot => array('metathingy' => $this->quba->get_question_attempt($this->slot))),
+                $this->observer->get_metadata_added());
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_set_metadata_previously_set_but_dont_actually_change() {
+        $this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
+        $this->observer = new testable_question_engine_unit_of_work($this->quba);
+        $this->quba->set_observer($this->observer);
+        $this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
+        $this->assertEquals('a value', $this->quba->get_question_attempt_metadata($this->slot, 'metathingy'));
+
+        $this->assertEquals(0, count($this->observer->get_attempts_added()));
+        $this->assertEquals(0, count($this->observer->get_attempts_modified()));
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_set_metadata_previously_set() {
+        $this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
+        $this->observer = new testable_question_engine_unit_of_work($this->quba);
+        $this->quba->set_observer($this->observer);
+        $this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'different value');
+        $this->assertEquals('different value', $this->quba->get_question_attempt_metadata($this->slot, 'metathingy'));
+
+        $this->assertEquals(0, count($this->observer->get_attempts_added()));
+        $this->assertEquals(0, count($this->observer->get_attempts_modified()));
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(array($this->slot => array('metathingy' => $this->quba->get_question_attempt($this->slot))),
+                $this->observer->get_metadata_modified());
+    }
+
+    public function test_set_metadata_in_new_question() {
+        $newslot = $this->quba->add_question(test_question_maker::make_question('truefalse'));
+        $this->quba->start_question($newslot);
+        $this->quba->set_question_attempt_metadata($newslot, 'metathingy', 'a value');
+        $this->assertEquals('a value', $this->quba->get_question_attempt_metadata($newslot, 'metathingy'));
+
+        $this->assertEquals(array($newslot => $this->quba->get_question_attempt($newslot)),
+                $this->observer->get_attempts_added());
+        $this->assertEquals(0, count($this->observer->get_attempts_modified()));
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(0, count($this->observer->get_metadata_added()));
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_set_metadata_then_move() {
+        $this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
+        $q = test_question_maker::make_question('truefalse');
+        $newslot = $this->quba->add_question_in_place_of_other($this->slot, $q);
+        $this->quba->start_question($this->slot);
+        $this->assertEquals('a value', $this->quba->get_question_attempt_metadata($newslot, 'metathingy'));
+
+        $this->assertEquals(array($this->slot => $this->quba->get_question_attempt($this->slot)),
+                $this->observer->get_attempts_added());
+        $this->assertEquals(array($newslot => $this->quba->get_question_attempt($newslot)),
+                $this->observer->get_attempts_modified());
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(array($newslot => array('metathingy' => $this->quba->get_question_attempt($newslot))),
+                $this->observer->get_metadata_added());
+        $this->assertEquals(0, count($this->observer->get_metadata_modified()));
+    }
+
+    public function test_move_then_set_metadata() {
+        $q = test_question_maker::make_question('truefalse');
+        $newslot = $this->quba->add_question_in_place_of_other($this->slot, $q);
+        $this->quba->start_question($this->slot);
+        $this->quba->set_question_attempt_metadata($newslot, 'metathingy', 'a value');
+        $this->assertEquals('a value', $this->quba->get_question_attempt_metadata($newslot, 'metathingy'));
+
+        $this->assertEquals(array($this->slot => $this->quba->get_question_attempt($this->slot)),
+                $this->observer->get_attempts_added());
+        $this->assertEquals(array($newslot => $this->quba->get_question_attempt($newslot)),
+                $this->observer->get_attempts_modified());
+
+        $this->assertEquals(0, count($this->observer->get_steps_added()));
+        $this->assertEquals(0, count($this->observer->get_steps_modified()));
+        $this->assertEquals(0, count($this->observer->get_steps_deleted()));
+
+        $this->assertEquals(array($newslot => array('metathingy' => $this->quba->get_question_attempt($newslot))),
+                $this->observer->get_metadata_added());
     }
 }
index bb77339..86f1529 100644 (file)
@@ -1,18 +1,52 @@
-This files describes API changes for the core question system.
+This files describes API changes for the core question engine.
+
+
+=== 2.9 ===
+
+1) Some new methods on the question_usage class (and corresponding methods on
+   question_attempt, question_attempt_step, question_usage_observer, ... requried
+   to implement them, but almost certainly you should only be calling the
+   question_usage methods from your code.
+
+   * question_usage::add_question_in_place_of_other($slot, $question, $maxmark = null)
+
+     This creates a new questoin_attempt in place of an existing one, moving the
+     existing question_attempt to the end of the usage, in a new slot number.
+     The new slot number is returned. The goal is to replace the old attempt, but
+     not lose the old data.
+
+   * question_usage::set_question_max_mark($slot, $maxmark)
+
+     Sets the max mark for one question in this usage. Previously, you could
+     only change this using the bulk operation question_usage::set_max_mark_in_attempts;
+
+   * question_usage::set_question_attempt_metadata($slot, $name, $value);
+     question_usage::get_question_attempt_metadata($slot, $name);
+
+     You can now record metadata, that is, values stored by name, against
+     question_attempts. The question engine ignores this data (other than storing
+     and loading it) but you may find it useful in your code.
+
+   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 ===
 
 1) The method question_behaviour::is_manual_grade_in_range and move and become
-question_engine::is_manual_grade_in_range.
+   question_engine::is_manual_grade_in_range.
 
 2) The arguments to core_question_renderer::mark_summary changed from
-($qa, $options) to ($qa, $behaviouroutput, $options). If you have overridden
-that method you will need to update your code.
+   ($qa, $options) to ($qa, $behaviouroutput, $options). If you have overridden
+   that method you will need to update your code.
 
 3) Heading level for number(), add_part_heading() and respond_history()
-has been lowered by one level. These changes are part of improving the page
-accessibility and making heading to have proper nesting. (MDL-41615)
+   has been lowered by one level. These changes are part of improving the page
+   accessibility and making heading to have proper nesting. (MDL-41615)
 
 === Earlier changes ===
 
index b71c27b..6e65e50 100644 (file)
@@ -22,7 +22,7 @@ This files describes API changes for code that uses the question API.
 
 === 2.8 ===
 
-1) This is jsut a warning that some methods of the question_engine_data_mapper
+1) This is just a warning that some methods of the question_engine_data_mapper
    class have changed. All these methods are ones that you should not have been
    calling directly from your code, so this should not cause any problems.
    The changed methods are: