diff --git a/mod/quiz/db/install.xml b/mod/quiz/db/install.xml old mode 100755 new mode 100644 diff --git a/mod/quiz/pix/icon.gif b/mod/quiz/pix/icon.gif old mode 100755 new mode 100644 diff --git a/question/behaviour/adaptive/behaviour.php b/question/behaviour/adaptive/behaviour.php new file mode 100644 index 00000000000..9f7db58b113 --- /dev/null +++ b/question/behaviour/adaptive/behaviour.php @@ -0,0 +1,178 @@ +. + + +/** + * Question behaviour for the old adaptive mode. + * + * @package qbehaviour_adaptive + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Question behaviour for adaptive mode. + * + * This is the old version of interactive mode. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_adaptive extends question_behaviour_with_save { + const IS_ARCHETYPAL = true; + + public function required_question_definition_type() { + return 'question_automatically_gradable'; + } + + public function get_expected_data() { + if ($this->qa->get_state()->is_active()) { + return array('submit' => PARAM_BOOL); + } + return parent::get_expected_data(); + } + + public function get_right_answer_summary() { + return $this->question->get_right_answer_summary(); + } + + public function adjust_display_options(question_display_options $options) { + parent::adjust_display_options($options); + if (!$this->qa->get_state()->is_finished() && + $this->qa->get_last_behaviour_var('_try')) { + $options->feedback = true; + } + } + + public function get_state_string($showcorrectness) { + $state = $this->qa->get_state(); + + $laststep = $this->qa->get_last_step(); + if ($laststep->has_behaviour_var('_try')) { + $state = question_state::graded_state_for_fraction( + $laststep->get_behaviour_var('_rawfraction')); + } + + return $state->default_string($showcorrectness); + } + + public function process_action(question_attempt_pending_step $pendingstep) { + if ($pendingstep->has_behaviour_var('comment')) { + return $this->process_comment($pendingstep); + } else if ($pendingstep->has_behaviour_var('finish')) { + return $this->process_finish($pendingstep); + } else if ($pendingstep->has_behaviour_var('submit')) { + return $this->process_submit($pendingstep); + } else { + return $this->process_save($pendingstep); + } + } + + public function summarise_action(question_attempt_step $step) { + if ($step->has_behaviour_var('comment')) { + return $this->summarise_manual_comment($step); + } else if ($step->has_behaviour_var('finish')) { + return $this->summarise_finish($step); + } else if ($step->has_behaviour_var('submit')) { + return $this->summarise_submit($step); + } else { + return $this->summarise_save($step); + } + } + + public function process_save(question_attempt_pending_step $pendingstep) { + $status = parent::process_save($pendingstep); + $prevgrade = $this->qa->get_fraction(); + if (!is_null($prevgrade)) { + $pendingstep->set_fraction($prevgrade); + } + $pendingstep->set_state(question_state::$todo); + return $status; + } + + protected function adjusted_fraction($fraction, $prevtries) { + return $fraction - $this->question->penalty * $prevtries; + } + + public function process_submit(question_attempt_pending_step $pendingstep) { + $status = $this->process_save($pendingstep); + + $response = $pendingstep->get_qt_data(); + if (!$this->question->is_gradable_response($response)) { + $pendingstep->set_state(question_state::$invalid); + if ($this->qa->get_state() != question_state::$invalid) { + $status = question_attempt::KEEP; + } + return $status; + } + + $prevtries = $this->qa->get_last_behaviour_var('_try', 0); + $prevbest = $pendingstep->get_fraction(); + if (is_null($prevbest)) { + $prevbest = 0; + } + + list($fraction, $state) = $this->question->grade_response($response); + + $pendingstep->set_fraction(max($prevbest, $this->adjusted_fraction($fraction, $prevtries))); + if ($state == question_state::$gradedright) { + $pendingstep->set_state(question_state::$complete); + } else { + $pendingstep->set_state(question_state::$todo); + } + $pendingstep->set_behaviour_var('_try', $prevtries + 1); + $pendingstep->set_behaviour_var('_rawfraction', $fraction); + $pendingstep->set_new_response_summary($this->question->summarise_response($response)); + + return question_attempt::KEEP; + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + $laststep = $this->qa->get_last_step(); + $response = $laststep->get_qt_data(); + if (!$this->question->is_gradable_response($response)) { + $pendingstep->set_state(question_state::$gaveup); + return question_attempt::KEEP; + } + + $prevtries = $this->qa->get_last_behaviour_var('_try', 0); + $prevbest = $pendingstep->get_fraction(); + if (is_null($prevbest)) { + $prevbest = 0; + } + + if ($laststep->has_behaviour_var('_try')) { + // Last answer was graded, we want to regrade it. Otherwise the answer + // has changed, and we are grading a new try. + $prevtries -= 1; + } + + list($fraction, $state) = $this->question->grade_response($response); + + $pendingstep->set_fraction(max($prevbest, $this->adjusted_fraction($fraction, $prevtries))); + $pendingstep->set_state($state); + $pendingstep->set_behaviour_var('_try', $prevtries + 1); + $pendingstep->set_behaviour_var('_rawfraction', $fraction); + $pendingstep->set_new_response_summary($this->question->summarise_response($response)); + return question_attempt::KEEP; + } +} diff --git a/question/behaviour/adaptive/lang/en_utf8/qbehaviour_adaptive.php b/question/behaviour/adaptive/lang/en_utf8/qbehaviour_adaptive.php new file mode 100644 index 00000000000..2de0227e0ac --- /dev/null +++ b/question/behaviour/adaptive/lang/en_utf8/qbehaviour_adaptive.php @@ -0,0 +1,6 @@ +raw/$a->max.'; +$string['gradingdetailsadjustment'] = 'With previous penalties this gives $a->cur/$a->max.'; +$string['gradingdetailspenalty'] = 'This submission attracted a penalty of $a.'; diff --git a/question/behaviour/adaptive/renderer.php b/question/behaviour/adaptive/renderer.php new file mode 100644 index 00000000000..7393d634826 --- /dev/null +++ b/question/behaviour/adaptive/renderer.php @@ -0,0 +1,98 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the legacy + * adaptive behaviour. + * + * @package qbehaviour_adaptive + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +class qbehaviour_adaptive_renderer extends qbehaviour_renderer { + protected function get_graded_step(question_attempt $qa) { + foreach ($qa->get_reverse_step_iterator() as $step) { + if ($step->has_behaviour_var('_try')) { + return $step; + } + } + } + + public function controls(question_attempt $qa, question_display_options $options) { + return $this->submit_button($qa, $options); + } + + public function feedback(question_attempt $qa, question_display_options $options) { + // Try to find the last graded step. + + $gradedstep = $this->get_graded_step($qa); + if (is_null($gradedstep) || $qa->get_max_mark() == 0 || !$options->marks) { + return ''; + } + + // Display the grading details from the last graded state + $mark = new stdClass; + $mark->max = $qa->format_max_mark($options->markdp); + + $actualmark = $gradedstep->get_fraction() * $qa->get_max_mark(); + $mark->cur = format_float($actualmark, $options->markdp); + + $rawmark = $gradedstep->get_behaviour_var('_rawfraction') * $qa->get_max_mark(); + $mark->raw = format_float($rawmark, $options->markdp); + + // let student know wether the answer was correct + if ($qa->get_state()->is_commented()) { + $class = $qa->get_state()->get_feedback_class(); + } else { + $class = question_state::graded_state_for_fraction( + $gradedstep->get_behaviour_var('_rawfraction'))->get_feedback_class(); + } + + $gradingdetails = get_string('gradingdetails', 'qbehaviour_adaptive', $mark); + + $gradingdetails .= $this->penalty_info($qa, $mark); + + $output = ''; + $output .= html_writer::tag('div', get_string($class, 'question'), + array('class' => 'correctness ' . $class)); + $output .= html_writer::tag('div', $gradingdetails, + array('class' => 'gradingdetails')); + return $output; + } + + protected function penalty_info($qa, $mark) { + if (!$qa->get_question()->penalty) { + return ''; + } + $output = ''; + + // print details of grade adjustment due to penalties + if ($mark->raw != $mark->cur) { + $output .= ' ' . get_string('gradingdetailsadjustment', 'qbehaviour_adaptive', $mark); + } + + // print info about new penalty + // penalty is relevant only if the answer is not correct and further attempts are possible + if (!$qa->get_state()->is_finished()) { + $output .= ' ' . get_string('gradingdetailspenalty', 'qbehaviour_adaptive', $qa->get_question()->penalty); + } + + return $output; + } +} diff --git a/question/behaviour/adaptive/simpletest/testwalkthrough.php b/question/behaviour/adaptive/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..c097f3d1894 --- /dev/null +++ b/question/behaviour/adaptive/simpletest/testwalkthrough.php @@ -0,0 +1,239 @@ +. + + +/** + * This file contains tests that walks a question through the adaptive + * behaviour. + * + * @package qbehaviour_adaptive + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_adaptive_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_adaptive_multichoice() { + + // Create a multiple choice, single response question. + $mc = test_question_maker::make_a_multichoice_single_question(); + $mc->penalty = 0.3333333; + $this->start_attempt_at_question($mc, 'adaptive', 3); + + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Process a submit. + $this->process_submission(array('answer' => $wrongindex, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(0); + $this->check_current_output( + $this->get_contains_mark_summary(0), + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 2) % 3, true, false), + $this->get_contains_incorrect_expectation()); + $this->assertPattern('/B|C/', + $this->quba->get_response_summary($this->slot)); + + // Process a change of answer to the right one, but not sumbitted. + $this->process_submission(array('answer' => $rightindex)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(0); + $this->check_current_output( + $this->get_contains_mark_summary(0), + $this->get_contains_mc_radio_expectation($rightindex, true, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, true, false)); + $this->assertPattern('/B|C/', + $this->quba->get_response_summary($this->slot)); + + // Now submit the right answer. + $this->process_submission(array('answer' => $rightindex, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(3 * (1 - $mc->penalty)); + $this->check_current_output( + $this->get_contains_mark_summary(3 * (1 - $mc->penalty)), + $this->get_contains_mc_radio_expectation($rightindex, true, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, true, false), + $this->get_contains_correct_expectation()); + $this->assertEqual('A', + $this->quba->get_response_summary($this->slot)); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(3 * (1 - $mc->penalty)); + $this->check_current_output( + $this->get_contains_mark_summary(3 * (1 - $mc->penalty)), + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, false, false), + $this->get_contains_correct_expectation()); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 1); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_contains_mark_summary(1), + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Now change the correct answer to the question, and regrade. + $mc->answers[13]->fraction = -0.33333333; + $mc->answers[15]->fraction = 1; + $this->quba->regrade_all_questions(); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_contains_mark_summary(1), + $this->get_contains_partcorrect_expectation()); + + $autogradedstep = $this->get_step($this->get_step_count() - 2); + $this->assertWithinMargin($autogradedstep->get_fraction(), 0, 0.0000001); + } + + public function test_adaptive_multichoice2() { + + // Create a multiple choice, multiple response question. + $mc = test_question_maker::make_a_multichoice_multi_question(); + $mc->penalty = 0.3333333; + $mc->shuffleanswers = 0; + $this->start_attempt_at_question($mc, 'adaptive', 2); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_question_text_expectation($mc), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Process a submit. + $this->process_submission(array('choice0' => 1, 'choice2' => 1, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(2); + $this->check_current_output( + $this->get_contains_mark_summary(2), + $this->get_contains_submit_button_expectation(true), + $this->get_contains_correct_expectation()); + + // Save the same correct answer again. Should no do anything. + $numsteps = $this->get_step_count(); + $this->process_submission(array('choice0' => 1, 'choice2' => 1)); + + // Verify. + $this->check_step_count($numsteps); + $this->check_current_state(question_state::$complete); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_step_count($numsteps + 1); + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(2); + $this->check_current_output( + $this->get_contains_mark_summary(2), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_correct_expectation()); + } + + public function test_adaptive_shortanswer_try_to_submit_blank() { + + // Create a short answer question with correct answer true. + $sa = test_question_maker::make_a_shortanswer_question(); + $this->start_attempt_at_question($sa, 'adaptive'); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Submit with blank answer. + $this->process_submission(array('-submit' => 1, 'answer' => '')); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_contains_validation_error_expectation()); + $this->assertNull($this->quba->get_response_summary($this->slot)); + + // Now get it wrong. + $this->process_submission(array('-submit' => 1, 'answer' => 'toad')); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(0.8); + $this->check_current_output( + $this->get_contains_mark_summary(0.8), + $this->get_contains_submit_button_expectation(true), + $this->get_contains_partcorrect_expectation(), + $this->get_does_not_contain_validation_error_expectation()); + + + // Now submit blank again. + $this->process_submission(array('-submit' => 1, 'answer' => '')); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(0.8); + $this->check_current_output( + $this->get_contains_mark_summary(0.8), + $this->get_contains_submit_button_expectation(true), + $this->get_contains_partcorrect_expectation(), + $this->get_contains_validation_error_expectation()); + } +} diff --git a/question/behaviour/adaptivenopenalty/behaviour.php b/question/behaviour/adaptivenopenalty/behaviour.php new file mode 100644 index 00000000000..9a6dee296c1 --- /dev/null +++ b/question/behaviour/adaptivenopenalty/behaviour.php @@ -0,0 +1,43 @@ +. + + +/** + * Question behaviour for the old adaptive mode, with no penalties. + * + * @package qbehaviour_adaptivenopenalty + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(dirname(__FILE__) . '/../adaptive/behaviour.php'); + +/** + * Question behaviour for adaptive mode, with no penalties. + * + * This is the old version of interactive mode, without penalties. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_adaptivenopenalty extends qbehaviour_adaptive { + const IS_ARCHETYPAL = true; + + protected function adjusted_fraction($fraction, $prevtries) { + return $fraction; + } +} diff --git a/question/behaviour/adaptivenopenalty/lang/en_utf8/qbehaviour_adaptivenopenalty.php b/question/behaviour/adaptivenopenalty/lang/en_utf8/qbehaviour_adaptivenopenalty.php new file mode 100644 index 00000000000..a78581056ea --- /dev/null +++ b/question/behaviour/adaptivenopenalty/lang/en_utf8/qbehaviour_adaptivenopenalty.php @@ -0,0 +1,3 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the legacy + * adaptive (no penalties) behaviour. + * + * @package qbehaviour_adaptivenopenalty + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(dirname(__FILE__) . '/../adaptive/renderer.php'); + +class qbehaviour_adaptivenopenalty_renderer extends qbehaviour_adaptive_renderer { + protected function penalty_info($qa, $mark) { + return ''; + } +} diff --git a/question/behaviour/adaptivenopenalty/simpletest/testwalkthrough.php b/question/behaviour/adaptivenopenalty/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..fe9cdef76bd --- /dev/null +++ b/question/behaviour/adaptivenopenalty/simpletest/testwalkthrough.php @@ -0,0 +1,186 @@ +. + + +/** + * This file contains tests that walks a question through the adaptive (no penalties)k + * behaviour. + * + * @package qbehaviour_adaptivenopenalty + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_adaptivenopenalty_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_multichoice() { + + // Create a multiple choice, single response question. + $mc = test_question_maker::make_a_multichoice_single_question(); + $mc->penalty = 0.3333333; + $this->start_attempt_at_question($mc, 'adaptivenopenalty', 3); + + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Process a submit. + $this->process_submission(array('answer' => $wrongindex, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(0); + $this->check_current_output( + $this->get_contains_mark_summary(0), + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 2) % 3, true, false), + $this->get_contains_incorrect_expectation()); + $this->assertPattern('/B|C/', + $this->quba->get_response_summary($this->slot)); + + // Process a change of answer to the right one, but not sumbitted. + $this->process_submission(array('answer' => $rightindex)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(0); + $this->check_current_output( + $this->get_contains_mark_summary(0), + $this->get_contains_mc_radio_expectation($rightindex, true, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, true, false)); + $this->assertPattern('/B|C/', + $this->quba->get_response_summary($this->slot)); + + // Now submit the right answer. + $this->process_submission(array('answer' => $rightindex, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(3); + $this->check_current_output( + $this->get_contains_mark_summary(3), + $this->get_contains_mc_radio_expectation($rightindex, true, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, true, false), + $this->get_contains_correct_expectation()); + $this->assertEqual('A', + $this->quba->get_response_summary($this->slot)); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(3); + $this->check_current_output( + $this->get_contains_mark_summary(3), + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, false, false), + $this->get_contains_correct_expectation()); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 1); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_contains_mark_summary(1), + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Now change the correct answer to the question, and regrade. + $mc->answers[13]->fraction = -0.33333333; + $mc->answers[15]->fraction = 1; + $this->quba->regrade_all_questions(); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_contains_mark_summary(1), + $this->get_contains_partcorrect_expectation()); + + $autogradedstep = $this->get_step($this->get_step_count() - 2); + $this->assertWithinMargin($autogradedstep->get_fraction(), 0, 0.0000001); + } + + public function test_multichoice2() { + + // Create a multiple choice, multiple response question. + $mc = test_question_maker::make_a_multichoice_multi_question(); + $mc->penalty = 0.3333333; + $mc->shuffleanswers = 0; + $this->start_attempt_at_question($mc, 'adaptivenopenalty', 2); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_question_text_expectation($mc), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Process a submit. + $this->process_submission(array('choice0' => 1, 'choice2' => 1, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(2); + $this->check_current_output( + $this->get_contains_mark_summary(2), + $this->get_contains_submit_button_expectation(true), + $this->get_contains_correct_expectation()); + + // Save the same correct answer again. Should no do anything. + $numsteps = $this->get_step_count(); + $this->process_submission(array('choice0' => 1, 'choice2' => 1)); + + // Verify. + $this->check_step_count($numsteps); + $this->check_current_state(question_state::$complete); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_step_count($numsteps + 1); + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(2); + $this->check_current_output( + $this->get_contains_mark_summary(2), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_correct_expectation()); + } +} diff --git a/question/behaviour/behaviourbase.php b/question/behaviour/behaviourbase.php new file mode 100644 index 00000000000..8b173438f63 --- /dev/null +++ b/question/behaviour/behaviourbase.php @@ -0,0 +1,631 @@ +. + + +/** + * Defines the quetsion behaviour base class + * + * @package moodlecore + * @subpackage questionbehaviours + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * The base class for question behaviours. + * + * A question behaviour is used by the question engine, specifically by + * a {@link question_attempt} to manage the flow of actions a student can take + * as they work through a question, and later, as a teacher manually grades it. + * In turn, the behaviour will delegate certain processing to the + * relevant {@link question_definition}. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_behaviour { + /** + * Certain behaviours are definitive of a way that questions can + * behave when attempted. For example deferredfeedback model, interactive + * model, etc. These are the options that should be listed in the + * user-interface. These models should define the class constant + * IS_ARCHETYPAL as true. Other models are more implementation details, for + * example the informationitem model, or a special subclass like + * interactive_adapted_for_my_qtype. These models should IS_ARCHETYPAL as + * false. + * @var boolean + */ + const IS_ARCHETYPAL = false; + + /** @var question_attempt the question attempt we are managing. */ + protected $qa; + /** @var question_definition shortcut to $qa->get_question(). */ + protected $question; + + /** + * Normally you should not call this constuctor directly. The appropriate + * behaviour object is created automatically as part of + * {@link question_attempt::start()}. + * @param question_attempt $qa the question attempt we will be managing. + * @param string $preferredbehaviour the type of behaviour that was actually + * requested. This information is not needed in most cases, the type of + * subclass is enough, but occasionally it is needed. + */ + public function __construct(question_attempt $qa, $preferredbehaviour) { + $this->qa = $qa; + $this->question = $qa->get_question(); + $requiredclass = $this->required_question_definition_type(); + if (!$this->question instanceof $requiredclass) { + throw new Exception('This behaviour (' . $this->get_name() . + ') cannot work with this question (' . get_class($this->question) . ')'); + } + } + + /** + * Most behaviours can only work with {@link question_definition}s + * of a particular subtype, or that implement a particular interface. + * This method lets the behaviour document that. The type of + * question passed to the constructor is then checked against this type. + * @return string class/interface name. + */ + public abstract function required_question_definition_type(); + + /** + * @return string the name of this behaviour. For example the name of + * qbehaviour_mymodle is 'mymodel'. + */ + public function get_name() { + return substr(get_class($this), 11); + } + + /** + * 'Override' this method if there are some display options that do not make + * sense 'during the attempt'. + * @return array of {@link question_display_options} field names, that are + * not relevant to this behaviour before a 'finish' action. + */ + public static function get_unused_display_options() { + return array(); + } + + /** + * Cause the question to be renderered. This gets the appropriate behaviour + * renderer using {@link get_renderer()}, and adjusts the display + * options using {@link adjust_display_options()} and then calls + * {@link core_question_renderer::question()} to do the work. + * @param question_display_options $options controls what should and should not be displayed. + * @param unknown_type $number the question number to display. + * @param core_question_renderer $qoutput the question renderer that will coordinate everything. + * @param qtype_renderer $qtoutput the question type renderer that will be helping. + * @return HTML fragment. + */ + public function render(question_display_options $options, $number, + core_question_renderer $qoutput, qtype_renderer $qtoutput) { + $behaviouroutput = $this->get_renderer(); + $options = clone($options); + $this->adjust_display_options($options); + return $qoutput->question($this->qa, $behaviouroutput, $qtoutput, $options, $number); + } + + /** + * @return qbehaviour_renderer get the appropriate renderer to use for this model. + */ + public function get_renderer() { + return renderer_factory::get_renderer(get_class($this)); + } + + /** + * Make any changes to the display options before a question is rendered, so + * that it can be displayed in a way that is appropriate for the statue it is + * currently in. For example, by default, if the question is finished, we + * ensure that it is only ever displayed read-only. + * @param question_display_options $options the options to adjust. Just change + * the properties of this object - objects are passed by referece. + */ + public function adjust_display_options(question_display_options $options) { + if (!$this->qa->has_marks()) { + $options->correctness = false; + $options->numpartscorrect = false; + } + if ($this->qa->get_state()->is_finished()) { + $options->readonly = true; + $options->numpartscorrect = $options->numpartscorrect && + $this->qa->get_state()->is_partially_correct() && + !empty($this->question->shownumcorrect); + } else { + $options->hide_all_feedback(); + } + } + + /** + * Get the most applicable hint for the question in its current state. + * @return question_hint the most applicable hint, or null, if none. + */ + public function get_applicable_hint() { + return null; + } + + /** + * What is the minimum fraction that can be scored for this question. + * Normally this will be based on $this->question->init_first_step($step), + * but may be modified in some way by the model. + * + * @return number the minimum fraction when this question is attempted under + * this model. + */ + public function get_min_fraction() { + return 0; + } + + /** + * 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 + * it is in. + * @param number $fraction the random guess score from the question type. + * @return number the adjusted fraction. + */ + public static function adjust_random_guess_score($fraction) { + return $fraction; + } + + /** + * Return an array of the behaviour variables that could be submitted + * as part of a question of this type, with their types, so they can be + * properly cleaned. + * @return array variable name => PARAM_... constant. + */ + public function get_expected_data() { + if (!$this->qa->get_state()->is_finished()) { + return array(); + } + + $vars = array('comment' => PARAM_RAW); + if ($this->qa->get_max_mark()) { + $vars['mark'] = question_attempt::PARAM_MARK; + $vars['maxmark'] = PARAM_NUMBER; + } + return $vars; + } + + /** + * Return an array of question type variables for the question in its current + * state. Normally, if {@link adjust_display_options()} would set + * {@link question_display_options::$readonly} to true, then this method + * should return an empty array, otherwise it should return + * $this->question->get_expected_data(). Thus, there should be little need to + * override this method. + * @return array|string variable name => PARAM_... constant, or, as a special case + * that should only be used in unavoidable, the constant question_attempt::USE_RAW_DATA + * meaning take all the raw submitted data belonging to this question. + */ + public function get_expected_qt_data() { + $fakeoptions = new question_display_options(); + $fakeoptions->readonly = false; + $this->adjust_display_options($fakeoptions); + if ($fakeoptions->readonly) { + return array(); + } else { + return $this->question->get_expected_data(); + } + } + + /** + * Return an array of any im variables, and the value required to get full + * marks. + * @return array variable name => value. + */ + public function get_correct_response() { + return array(); + } + + /** + * Generate a brief, plain-text, summary of this question. This is used by + * various reports. This should show the particular variant of the question + * as presented to students. For example, the calculated quetsion type would + * fill in the particular numbers that were presented to the student. + * This method will return null if such a summary is not possible, or + * inappropriate. + * + * Normally, this method delegates to {question_definition::get_question_summary()}. + * + * @return string|null a plain text summary of this question. + */ + public function get_question_summary() { + return $this->question->get_question_summary(); + } + + /** + * Generate a brief, plain-text, summary of the correct answer to this question. + * This is used by various reports, and can also be useful when testing. + * This method will return null if such a summary is not possible, or + * inappropriate. + * + * @return string|null a plain text summary of the right answer to this question. + */ + public function get_right_answer_summary() { + return null; + } + + /** + * Used by {@link start_based_on()} to get the data needed to start a new + * attempt from the point this attempt has go to. + * @return array name => value pairs. + */ + public function get_resume_data() { + $olddata = $this->qa->get_step(0)->get_all_data(); + $olddata = $this->qa->get_last_qt_data() + $olddata; + $olddata = $this->get_our_resume_data() + $olddata; + return $olddata; + } + + /** + * Used by {@link start_based_on()} to get the data needed to start a new + * attempt from the point this attempt has go to. + * @return unknown_type + */ + protected function get_our_resume_data() { + return array(); + } + + /** + * @return array subpartid => object with fields + * ->responseclassid the + * ->response the actual response the student gave to this part, as a string. + * ->fraction the credit awarded for this subpart, may be null. + * returns an empty array if no analysis is possible. + */ + public function classify_response() { + return $this->question->classify_response($this->qa->get_last_qt_data()); + } + + /** + * Generate a brief textual description of the current state of the question, + * normally displayed under the question number. + * + * @param boolean $showcorrectness Whether right/partial/wrong states should + * be distinguised. + * @return string a brief summary of the current state of the qestion attempt. + */ + public function get_state_string($showcorrectness) { + return $this->qa->get_state()->default_string($showcorrectness); + } + + abstract public function summarise_action(question_attempt_step $step); + + /** + * Initialise the first step in a question attempt. + * + * This method must call $this->question->init_first_step($step), and may + * perform additional processing if the model requries it. + * + * @param question_attempt_step $step the step being initialised. + */ + public function init_first_step(question_attempt_step $step) { + $this->question->init_first_step($step); + } + + /** + * Checks whether two manual grading actions are the same. That is, whether + * the comment, and the mark (if given) is the same. + * + * @param question_attempt_step $pendingstep contains the new responses. + * @return boolean whether the new response is the same as we already have. + */ + protected function is_same_comment($pendingstep) { + $previouscomment = $this->qa->get_last_behaviour_var('comment'); + $newcomment = $pendingstep->get_behaviour_var('comment'); + + if (is_null($previouscomment) && !html_is_blank($newcomment) || + $previouscomment != $newcomment) { + return false; + } + + // So, now we know the comment is the same, so check the mark, if present. + $previousfraction = $this->qa->get_fraction(); + $newmark = $pendingstep->get_behaviour_var('mark'); + + if (is_null($previousfraction)) { + return is_null($newmark) || $newmark === ''; + } else if (is_null($newmark) || $newmark === '') { + return false; + } + + $newfraction = $newmark / $pendingstep->get_behaviour_var('maxmark'); + + return abs($newfraction - $previousfraction) < 0.0000001; + } + + /** + * The main entry point for processing an action. + * + * All the various operations that can be performed on a + * {@link question_attempt} get channeled through this function, except for + * {@link question_attempt::start()} which goes to {@link init_first_step()}. + * {@link question_attempt::finish()} becomes an action with im vars + * finish => 1, and manual comment/grade becomes an action with im vars + * comment => comment text, and mark => ..., max_mark => ... if the question + * is graded. + * + * This method should first determine whether the action is significant. For + * example, if no actual action is being performed, but instead the current + * responses are being saved, and there has been no change since the last + * set of responses that were saved, this the action is not significatn. In + * this case, this method should return {@link question_attempt::DISCARD}. + * Otherwise it should return {@link question_attempt::KEEP}. + * + * If the action is significant, this method should also perform any + * necessary updates to $pendingstep. For example, it should call + * {@link question_attempt_step::set_state()} to set the state that results + * from this action, and if this is a grading action, it should call + * {@link question_attempt_step::set_fraction()}. + * + * This method can also call {@link question_attempt_step::set_behaviour_var()} to + * store additional infomation. There are two main uses for this. This can + * be used to store the result of any randomisation done. It is important to + * store the result of randomisation once, and then in future use the same + * outcome if the actions are ever replayed. This is how regrading works. + * The other use is to cache the result of expensive computations performed + * on the raw response data, so that subsequent display and review of the + * question does not have to repeat the same expensive computations. + * + * Often this method is implemented as a dispatching method that examines + * the pending step to determine the kind of action being performed, and + * then calls a more specific method like {@link process_save()} or + * {@link process_comment()}. Look at some of the standard behaviours + * for examples. + * + * @param question_attempt_pending_step $pendingstep a partially initialised step + * containing all the information about the action that is being peformed. + * This information can be accessed using {@link question_attempt_step::get_behaviour_var()}. + * @return boolean either {@link question_attempt::KEEP} or {@link question_attempt::DISCARD} + */ + public abstract function process_action(question_attempt_pending_step $pendingstep); + + /** + * Implementation of processing a manual comment/grade action that should + * be suitable for most subclasses. + * @param question_attempt_pending_step $pendingstep a partially initialised step + * containing all the information about the action that is being peformed. + * @return boolean either {@link question_attempt::KEEP} + */ + public function process_comment(question_attempt_pending_step $pendingstep) { + if (!$this->qa->get_state()->is_finished()) { + throw new coding_exception('Cannot manually grade a question before it is finshed.'); + } + + if ($this->is_same_comment($pendingstep)) { + return question_attempt::DISCARD; + } + + if ($pendingstep->has_behaviour_var('mark')) { + $fraction = $pendingstep->get_behaviour_var('mark') / $pendingstep->get_behaviour_var('maxmark'); + if ($pendingstep->get_behaviour_var('mark') === '') { + $fraction = null; + } else if ($fraction > 1 || $fraction < $this->qa->get_min_fraction()) { + throw new coding_exception('Score out of range when processing ' . + 'a manual grading action.', $pendingstep); + } + $pendingstep->set_fraction($fraction); + } + + $pendingstep->set_state($this->qa->get_state()-> + corresponding_commented_state($pendingstep->get_fraction())); + return question_attempt::KEEP; + } + + /** + * @param $comment the comment text to format. If omitted, + * $this->qa->get_manual_comment() is used. + * @return string the comment, ready to be output. + */ + public function format_comment($comment = null) { + $formatoptions = new stdClass; + $formatoptions->noclean = true; + $formatoptions->para = false; + + if (is_null($comment)) { + $comment = $this->qa->get_manual_comment(); + } + + return format_text($comment, FORMAT_HTML, $formatoptions); + } + + /** + * @return string a summary of a manual comment action. + * @param unknown_type $step + */ + protected function summarise_manual_comment($step) { + $a = new stdClass; + if ($step->has_behaviour_var('comment')) { + $a->comment = shorten_text(html_to_text($this->format_comment( + $step->get_behaviour_var('comment')), 0, false), 200); + } else { + $a->comment = ''; + } + + $mark = $step->get_behaviour_var('mark'); + if (is_null($mark) || $mark === '') { + return get_string('commented', 'question', $a->comment); + } else { + $a->mark = $mark / $step->get_behaviour_var('maxmark') * $this->qa->get_max_mark(); + return get_string('manuallygraded', 'question', $a); + } + } + + public function summarise_start($step) { + return get_string('started', 'question'); + } + + public function summarise_finish($step) { + return get_string('attemptfinished', 'question'); + } +} + + +/** + * A subclass of {@link question_behaviour} that implements a save + * action that is suitable for most questions that implement the + * {@link question_manually_gradable} interface. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_behaviour_with_save extends question_behaviour { + public function required_question_definition_type() { + return 'question_manually_gradable'; + } + + /** + * Work out whether the response in $pendingstep are significantly different + * from the last set of responses we have stored. + * @param question_attempt_step $pendingstep contains the new responses. + * @return boolean whether the new response is the same as we already have. + */ + protected function is_same_response(question_attempt_step $pendingstep) { + return $this->question->is_same_response( + $this->qa->get_last_step()->get_qt_data(), $pendingstep->get_qt_data()); + } + + /** + * Work out whether the response in $pendingstep represent a complete answer + * to the question. Normally this will call + * {@link question_manually_gradable::is_complete_response}, but some + * behaviours, for example the CBM ones, have their own parts to the + * response. + * @param question_attempt_step $pendingstep contains the new responses. + * @return boolean whether the new response is complete. + */ + protected function is_complete_response(question_attempt_step $pendingstep) { + return $this->question->is_complete_response($pendingstep->get_qt_data()); + } + + /** + * Implementation of processing a save action that should be suitable for + * most subclasses. + * @param question_attempt_pending_step $pendingstep a partially initialised step + * containing all the information about the action that is being peformed. + * @return boolean either {@link question_attempt::KEEP} or {@link question_attempt::DISCARD} + */ + public function process_save(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } else if (!$this->qa->get_state()->is_active()) { + throw new Exception('Question is not active, cannot process_actions.'); + } + + if ($this->is_same_response($pendingstep)) { + return question_attempt::DISCARD; + } + + if ($this->is_complete_response($pendingstep)) { + $pendingstep->set_state(question_state::$complete); + } else { + $pendingstep->set_state(question_state::$todo); + } + return question_attempt::KEEP; + } + + public function summarise_submit(question_attempt_step $step) { + return get_string('submitted', 'question', + $this->question->summarise_response($step->get_qt_data())); + } + + public function summarise_save(question_attempt_step $step) { + $data = $step->get_submitted_data(); + if (empty($data)) { + return $this->summarise_start($step); + } + return get_string('saved', 'question', + $this->question->summarise_response($step->get_qt_data())); + } + + + public function summarise_finish($step) { + $data = $step->get_qt_data(); + if ($data) { + return get_string('attemptfinishedsubmitting', 'question', + $this->question->summarise_response($data)); + } + return get_string('attemptfinished', 'question'); + } +} + + +/** + * This helper class contains the constants and methods required for + * manipulating scores for certainly based marking. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_cbm { + /**#@+ @var integer named constants for the certainty levels. */ + const LOW = 1; + const MED = 2; + const HIGH = 3; + /**#@-*/ + + /** @var array list of all the certainty levels. */ + public static $certainties = array(self::LOW, self::MED, self::HIGH); + + /**#@+ @var array coefficients used to adjust the fraction based on certainty.. */ + protected static $factor = array( + self::LOW => 0.333333333333333, + self::MED => 1.333333333333333, + self::HIGH => 3, + ); + protected static $offset = array( + self::LOW => 0, + self::MED => -0.666666666666667, + self::HIGH => -2, + ); + /**#@-*/ + + /** + * @return integer the default certaintly level that should be assuemd if + * the student does not choose one. + */ + public static function default_certainty() { + return self::LOW; + } + + /** + * Given a fraction, and a certainly, compute the adjusted fraction. + * @param number $fraction the raw fraction for this question. + * @param integer $certainty one of the certainly level constants. + * @return number the adjusted fraction taking the certainly into account. + */ + public static function adjust_fraction($fraction, $certainty) { + return self::$offset[$certainty] + self::$factor[$certainty] * $fraction; + } + + /** + * @param integer $certainty one of the LOW/MED/HIGH constants. + * @return string a textual desciption of this certainly. + */ + public static function get_string($certainty) { + return get_string('certainty' . $certainty, 'qbehaviour_deferredcbm'); + } + + public static function summary_with_certainty($summary, $certainty) { + if (is_null($certainty)) { + return $summary; + } + return $summary . ' [' . self::get_string($certainty) . ']'; + } +} diff --git a/question/behaviour/deferredcbm/behaviour.php b/question/behaviour/deferredcbm/behaviour.php new file mode 100644 index 00000000000..02c460031a3 --- /dev/null +++ b/question/behaviour/deferredcbm/behaviour.php @@ -0,0 +1,125 @@ +. + + +/** + * Question behaviour that is like the deferred feedback model, but with + * certainly based marking. That is, in addition to the other controls, there are + * where the student can indicate how certain they are that their answer is right. + * + * @package qbehaviour_deferredcbm + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../deferredfeedback/behaviour.php'); + +/** + * Question behaviour for deferred feedback with certainty based marking. + * + * The student enters their response during the attempt, along with a certainty, + * that is, how sure they are that they are right, and it is saved. Later, + * when the whole attempt is finished, their answer is graded. Their degree + * of certainty affects their score. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_deferredcbm extends qbehaviour_deferredfeedback { + const IS_ARCHETYPAL = true; + + public static function get_unused_display_options() { + return array('correctness', 'marks', 'specificfeedback', 'generalfeedback', + 'rightanswer'); + } + + public function get_min_fraction() { + return question_cbm::adjust_fraction(parent::get_min_fraction(), question_cbm::HIGH); + } + + public function get_expected_data() { + if ($this->qa->get_state()->is_active()) { + return array('certainty' => PARAM_INT); + } + return parent::get_expected_data(); + } + + public function get_right_answer_summary() { + $summary = parent::get_right_answer_summary(); + return $summary . ' [' . question_cbm::get_string(question_cbm::HIGH) . ']'; + } + + public function get_correct_response() { + if ($this->qa->get_state()->is_active()) { + return array('certainty' => question_cbm::HIGH); + } + return array(); + } + + protected function get_our_resume_data() { + $lastcertainty = $this->qa->get_last_behaviour_var('certainty'); + if ($lastcertainty) { + return array('-certainty' => $lastcertainty); + } else { + return array(); + } + } + + protected function is_same_response($pendingstep) { + return parent::is_same_response($pendingstep) && + $this->qa->get_last_behaviour_var('certainty') == $pendingstep->get_behaviour_var('certainty'); + } + + protected function is_complete_response($pendingstep) { + return parent::is_complete_response($pendingstep) && $pendingstep->has_behaviour_var('certainty'); + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + $status = parent::process_finish($pendingstep); + if ($status == question_attempt::KEEP) { + $fraction = $pendingstep->get_fraction(); + if ($this->qa->get_last_step()->has_behaviour_var('certainty')) { + $certainty = $this->qa->get_last_step()->get_behaviour_var('certainty'); + } else { + $certainty = question_cbm::default_certainty(); + $pendingstep->set_behaviour_var('_assumedcertainty', $certainty); + } + if (!is_null($fraction)) { + $pendingstep->set_behaviour_var('_rawfraction', $fraction); + $pendingstep->set_fraction(question_cbm::adjust_fraction($fraction, $certainty)); + } + $pendingstep->set_new_response_summary( + question_cbm::summary_with_certainty($pendingstep->get_new_response_summary(), + $this->qa->get_last_step()->get_behaviour_var('certainty'))); + } + return $status; + } + + public function summarise_action(question_attempt_step $step) { + $summary = parent::summarise_action($step); + if ($step->has_behaviour_var('certainty')) { + $summary = question_cbm::summary_with_certainty($summary, + $step->get_behaviour_var('certainty')); + } + return $summary; + } + + public static function adjust_random_guess_score($fraction) { + return question_cbm::adjust_fraction($fraction, question_cbm::default_certainty()); + } +} diff --git a/question/behaviour/deferredcbm/lang/en_utf8/qbehaviour_deferredcbm.php b/question/behaviour/deferredcbm/lang/en_utf8/qbehaviour_deferredcbm.php new file mode 100644 index 00000000000..d065d5ecd53 --- /dev/null +++ b/question/behaviour/deferredcbm/lang/en_utf8/qbehaviour_deferredcbm.php @@ -0,0 +1,9 @@ +rawmark} was adjusted to {$a->mark}.'; diff --git a/question/behaviour/deferredcbm/renderer.php b/question/behaviour/deferredcbm/renderer.php new file mode 100644 index 00000000000..2f5f6e62eb4 --- /dev/null +++ b/question/behaviour/deferredcbm/renderer.php @@ -0,0 +1,87 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the deferred + * feedback behaviour. + * + * @package qbehaviour_deferredcbm + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +class qbehaviour_deferredcbm_renderer extends qbehaviour_renderer { + protected function certainly_choices($controlname, $selected, $readonly) { + $attributes = array( + 'type' => 'radio', + 'name' => $controlname, + ); + if ($readonly) { + $attributes['disabled'] = 'disabled'; + } + + $choices = ''; + foreach (question_cbm::$certainties as $certainty) { + $id = $controlname . $certainty; + $attributes['id'] = $id; + $attributes['value'] = $certainty; + if ($selected == $certainty) { + $attributes['checked'] = 'checked'; + } else { + unset($attributes['checked']); + } + $choices .= ' ' . html_writer::empty_tag('input', $attributes) . ' ' . + html_writer::tag('label', question_cbm::get_string($certainty), + array('for' => $id)); + } + return $choices; + } + + public function controls(question_attempt $qa, question_display_options $options) { + return html_writer::tag('div', get_string('howcertainareyou', 'qbehaviour_deferredcbm', + $this->certainly_choices($qa->get_behaviour_field_name('certainty'), + $qa->get_last_behaviour_var('certainty'), $options->readonly)), + array('class' => 'certaintychoices')); + } + + public function feedback(question_attempt $qa, question_display_options $options) { + if (!$options->feedback) { + return ''; + } + + if ($qa->get_state() == question_state::$gaveup || $qa->get_state() == question_state::$mangaveup) { + return ''; + } + + $feedback = ''; + if (!$qa->get_last_behaviour_var('certainty')) { + $feedback .= html_writer::tag('p', get_string('assumingcertainty', 'qbehaviour_deferredcbm', + question_cbm::get_string($qa->get_last_behaviour_var('_assumedcertainty')))); + } + + if ($options->marks) { + $a->rawmark = format_float( + $qa->get_last_behaviour_var('_rawfraction') * $qa->get_max_mark(), $options->markdp); + $a->mark = $qa->format_mark($options->markdp); + $feedback .= html_writer::tag('p', get_string('markadjustment', 'qbehaviour_deferredcbm', $a)); + } + + return $feedback; + } +} \ No newline at end of file diff --git a/question/behaviour/deferredcbm/simpletest/testwalkthrough.php b/question/behaviour/deferredcbm/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..742911d68ba --- /dev/null +++ b/question/behaviour/deferredcbm/simpletest/testwalkthrough.php @@ -0,0 +1,261 @@ +. + + +/** + * This file contains tests that walks a question through the deferred feedback + * behaviour. + * + * @package qbehaviour_deferredcbm + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_deferredcbm_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_deferred_cbm_truefalse_high_certainty() { + + // Create a true-false question with correct answer true. + $tf = test_question_maker::make_a_truefalse_question(); + $this->start_attempt_at_question($tf, 'deferredcbm', 2); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($tf), + $this->get_contains_tf_true_radio_expectation(true, false), + $this->get_contains_tf_false_radio_expectation(true, false), + $this->get_contains_cbm_radio_expectation(1, true, false), + $this->get_contains_cbm_radio_expectation(2, true, false), + $this->get_contains_cbm_radio_expectation(3, true, false), + $this->get_does_not_contain_feedback_expectation()); + + // Process the data extracted for this question. + $this->process_submission(array('answer' => 1, '-certainty' => 3)); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_tf_true_radio_expectation(true, true), + $this->get_contains_cbm_radio_expectation(3, true, true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation()); + + // Process the same data again, check it does not create a new step. + $numsteps = $this->get_step_count(); + $this->process_submission(array('answer' => 1, '-certainty' => 3)); + $this->check_step_count($numsteps); + + // Process different data, check it creates a new step. + $this->process_submission(array('answer' => 1, '-certainty' => 1)); + $this->check_step_count($numsteps + 1); + $this->check_current_state(question_state::$complete); + + // Change back, check it creates a new step. + $this->process_submission(array('answer' => 1, '-certainty' => 3)); + $this->check_step_count($numsteps + 2); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(2); + $this->check_current_output( + $this->get_contains_tf_true_radio_expectation(false, true), + $this->get_contains_cbm_radio_expectation(3, false, true), + $this->get_contains_correct_expectation()); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 1); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + $this->check_current_output(new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Now change the correct answer to the question, and regrade. + $tf->rightanswer = false; + $this->quba->regrade_all_questions(); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + $autogradedstep = $this->get_step($this->get_step_count() - 2); + $this->assertWithinMargin($autogradedstep->get_fraction(), -2, 0.0000001); + } + + public function test_deferred_cbm_truefalse_low_certainty() { + + // Create a true-false question with correct answer true. + $tf = test_question_maker::make_a_truefalse_question(); + $this->start_attempt_at_question($tf, 'deferredcbm', 2); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_does_not_contain_correctness_expectation(), + $this->get_contains_cbm_radio_expectation(1, true, false), + $this->get_does_not_contain_feedback_expectation()); + + // Submit ansewer with low certainty. + $this->process_submission(array('answer' => 1, '-certainty' => 1)); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + $this->check_current_output($this->get_does_not_contain_correctness_expectation(), + $this->get_contains_cbm_radio_expectation(1, true, true), + $this->get_does_not_contain_feedback_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(0.6666667); + $this->check_current_output($this->get_contains_correct_expectation(), + $this->get_contains_cbm_radio_expectation(1, false, true)); + $this->assertEqual(get_string('true', 'qtype_truefalse') . ' [' . question_cbm::get_string(1) . ']', + $this->quba->get_response_summary($this->slot)); + } + + public function test_deferred_cbm_truefalse_default_certainty() { + + // Create a true-false question with correct answer true. + $tf = test_question_maker::make_a_truefalse_question(); + $this->start_attempt_at_question($tf, 'deferredcbm', 2); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_does_not_contain_correctness_expectation(), + $this->get_contains_cbm_radio_expectation(1, true, false), + $this->get_does_not_contain_feedback_expectation()); + + // Submit ansewer with low certainty and finish the attempt. + $this->process_submission(array('answer' => 1)); + $this->quba->finish_all_questions(); + + // Verify. + $qa = $this->quba->get_question_attempt($this->slot); + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(0.6666667); + $this->check_current_output($this->get_contains_correct_expectation(), + $this->get_contains_cbm_radio_expectation(1, false, false), + new PatternExpectation('/' . preg_quote(get_string('assumingcertainty', 'qbehaviour_deferredcbm', + question_cbm::get_string($qa->get_last_behaviour_var('_assumedcertainty')))) . '/')); + $this->assertEqual(get_string('true', 'qtype_truefalse'), + $this->quba->get_response_summary($this->slot)); + } + + public function test_deferredcbm_resume_multichoice_single() { + + // Create a multiple-choice question. + $mc = test_question_maker::make_a_multichoice_single_question(); + + // Attempt it getting it wrong. + $this->start_attempt_at_question($mc, 'deferredcbm', 3); + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + $this->process_submission(array('answer' => $wrongindex, '-certainty' => 2)); + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedwrong); + $this->check_current_mark(-3.3333333); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, false, true), + $this->get_contains_cbm_radio_expectation(2, false, true), + $this->get_contains_incorrect_expectation()); + $this->assertEqual('A [' . question_cbm::get_string(question_cbm::HIGH) . ']', + $this->quba->get_right_answer_summary($this->slot)); + $this->assertPattern('/' . preg_quote($mc->questiontext) . '/', + $this->quba->get_question_summary($this->slot)); + $this->assertPattern('/(B|C) \[' . preg_quote(question_cbm::get_string(2)) . '\]/', + $this->quba->get_response_summary($this->slot)); + + // Save the old attempt. + $oldqa = $this->quba->get_question_attempt($this->slot); + + // Reinitialise. + $this->setUp(); + $this->quba->set_preferred_behaviour('deferredcbm'); + $this->slot = $this->quba->add_question($mc, 3); + $this->quba->start_question_based_on($this->slot, $oldqa); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_cbm_radio_expectation(2, true, true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_does_not_contain_correctness_expectation()); + $this->assertEqual('A [' . question_cbm::get_string(question_cbm::HIGH) . ']', + $this->quba->get_right_answer_summary($this->slot)); + $this->assertPattern('/' . preg_quote($mc->questiontext) . '/', + $this->quba->get_question_summary($this->slot)); + $this->assertNull($this->quba->get_response_summary($this->slot)); + + // Now get it right. + $this->process_submission(array('answer' => $rightindex, '-certainty' => 3)); + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(3); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_cbm_radio_expectation(3, false, true), + $this->get_contains_correct_expectation()); + $this->assertPattern('/(A) \[' . preg_quote(question_cbm::get_string(3)) . '\]/', + $this->quba->get_response_summary($this->slot)); + } + + public function test_deferred_cbm_truefalse_no_certainty_feedback_when_not_answered() { + + // Create a true-false question with correct answer true. + $tf = test_question_maker::make_a_truefalse_question(); + $this->start_attempt_at_question($tf, 'deferredcbm', 2); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_does_not_contain_correctness_expectation(), + $this->get_contains_cbm_radio_expectation(1, true, false), + $this->get_does_not_contain_feedback_expectation()); + + // Finish without answering. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gaveup); + $this->check_current_mark(null); + $this->check_current_output( + new NoPatternExpectation('/class=\"im-feedback/')); + } +} + diff --git a/question/behaviour/deferredfeedback/behaviour.php b/question/behaviour/deferredfeedback/behaviour.php new file mode 100644 index 00000000000..937799aad6d --- /dev/null +++ b/question/behaviour/deferredfeedback/behaviour.php @@ -0,0 +1,94 @@ +. + + +/** + * Question behaviour for the case when the student's answer is just + * saved until they submit the whole attempt, and then it is graded. + * + * @package qbehaviour_deferredfeedback + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Question behaviour for deferred feedback. + * + * The student enters their response during the attempt, and it is saved. Later, + * when the whole attempt is finished, their answer is graded. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_deferredfeedback extends question_behaviour_with_save { + const IS_ARCHETYPAL = true; + + public function required_question_definition_type() { + return 'question_automatically_gradable'; + } + + public static function get_unused_display_options() { + return array('correctness', 'marks', 'specificfeedback', 'generalfeedback', + 'rightanswer'); + } + + public function get_min_fraction() { + return $this->question->get_min_fraction(); + } + + public function get_right_answer_summary() { + return $this->question->get_right_answer_summary(); + } + + public function process_action(question_attempt_pending_step $pendingstep) { + if ($pendingstep->has_behaviour_var('comment')) { + return $this->process_comment($pendingstep); + } else if ($pendingstep->has_behaviour_var('finish')) { + return $this->process_finish($pendingstep); + } else { + return $this->process_save($pendingstep); + } + } + + public function summarise_action(question_attempt_step $step) { + if ($step->has_behaviour_var('comment')) { + return $this->summarise_manual_comment($step); + } else if ($step->has_behaviour_var('finish')) { + return $this->summarise_finish($step); + } else { + return $this->summarise_save($step); + } + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + $response = $this->qa->get_last_step()->get_qt_data(); + if (!$this->question->is_gradable_response($response)) { + $pendingstep->set_state(question_state::$gaveup); + } else { + list($fraction, $state) = $this->question->grade_response($response); + $pendingstep->set_fraction($fraction); + $pendingstep->set_state($state); + } + $pendingstep->set_new_response_summary($this->question->summarise_response($response)); + return question_attempt::KEEP; + } +} diff --git a/question/behaviour/deferredfeedback/lang/en_utf8/qbehaviour_deferredfeedback.php b/question/behaviour/deferredfeedback/lang/en_utf8/qbehaviour_deferredfeedback.php new file mode 100644 index 00000000000..a6646f6837a --- /dev/null +++ b/question/behaviour/deferredfeedback/lang/en_utf8/qbehaviour_deferredfeedback.php @@ -0,0 +1,3 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the deferred + * feedback behaviour. + * + * @package qbehaviour_deferredfeedback + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +class qbehaviour_deferredfeedback_renderer extends qbehaviour_renderer { +} diff --git a/question/behaviour/deferredfeedback/simpletest/testwalkthrough.php b/question/behaviour/deferredfeedback/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..8f4eee1712b --- /dev/null +++ b/question/behaviour/deferredfeedback/simpletest/testwalkthrough.php @@ -0,0 +1,205 @@ +. + + +/** + * This file contains tests that walks a question through the deferred feedback + * behaviour. + * + * @package qbehaviour_deferredfeedback + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_deferredfeedback_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_deferredfeedback_feedback_truefalse() { + + // Create a true-false question with correct answer true. + $tf = test_question_maker::make_a_truefalse_question(); + $this->start_attempt_at_question($tf, 'deferredfeedback', 2); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output($this->get_contains_question_text_expectation($tf), + $this->get_does_not_contain_feedback_expectation()); + $this->assertEqual(get_string('true', 'qtype_truefalse'), + $this->quba->get_right_answer_summary($this->slot)); + $this->assertPattern('/' . preg_quote($tf->questiontext) . '/', + $this->quba->get_question_summary($this->slot)); + $this->assertNull($this->quba->get_response_summary($this->slot)); + + // Process a true answer and check the expected result. + $this->process_submission(array('answer' => 1)); + + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + $this->check_current_output($this->get_contains_tf_true_radio_expectation(true, true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation()); + + // Process the same data again, check it does not create a new step. + $numsteps = $this->get_step_count(); + $this->process_submission(array('answer' => 1)); + $this->check_step_count($numsteps); + + // Process different data, check it creates a new step. + $this->process_submission(array('answer' => 0)); + $this->check_step_count($numsteps + 1); + $this->check_current_state(question_state::$complete); + + // Change back, check it creates a new step. + $this->process_submission(array('answer' => 1)); + $this->check_step_count($numsteps + 2); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(2); + $this->check_current_output($this->get_contains_correct_expectation(), + $this->get_contains_tf_true_radio_expectation(false, true), + new PatternExpectation('/class="r0 correct"/')); + $this->assertEqual(get_string('true', 'qtype_truefalse'), + $this->quba->get_response_summary($this->slot)); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 1); + + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + $this->check_current_output( + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Now change the correct answer to the question, and regrade. + $tf->rightanswer = false; + $this->quba->regrade_all_questions(); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + + $autogradedstep = $this->get_step($this->get_step_count() - 2); + $this->assertWithinMargin($autogradedstep->get_fraction(), 0, 0.0000001); + } + + public function test_deferredfeedback_feedback_multichoice_single() { + + // Create a true-false question with correct answer true. + $mc = test_question_maker::make_a_multichoice_single_question(); + $this->start_attempt_at_question($mc, 'deferredfeedback', 3); + + // Start a deferred feedback attempt and add the question to it. + $rightindex = $this->get_mc_right_answer_index($mc); + + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_does_not_contain_feedback_expectation()); + + // Process the data extracted for this question. + $this->process_submission(array('answer' => $rightindex)); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, true, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(3); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_correct_expectation()); + + // Now change the correct answer to the question, and regrade. + $mc->answers[13]->fraction = -0.33333333; + $mc->answers[14]->fraction = 1; + $this->quba->regrade_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedwrong); + $this->check_current_mark(-1); + $this->check_current_output( + $this->get_contains_incorrect_expectation()); + } + + public function test_deferredfeedback_resume_multichoice_single() { + + // Create a multiple-choice question. + $mc = test_question_maker::make_a_multichoice_single_question(); + + // Attempt it getting it wrong. + $this->start_attempt_at_question($mc, 'deferredfeedback', 3); + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + $this->process_submission(array('answer' => $wrongindex)); + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedwrong); + $this->check_current_mark(-1); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, false, true), + $this->get_contains_incorrect_expectation()); + + // Save the old attempt. + $oldqa = $this->quba->get_question_attempt($this->slot); + + // Reinitialise. + $this->setUp(); + $this->quba->set_preferred_behaviour('deferredfeedback'); + $this->slot = $this->quba->add_question($mc, 3); + $this->quba->start_question_based_on($this->slot, $oldqa); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_does_not_contain_correctness_expectation()); + + // Now get it right. + $this->process_submission(array('answer' => $rightindex)); + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(3); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_correct_expectation()); + } +} diff --git a/question/behaviour/immediatecbm/behaviour.php b/question/behaviour/immediatecbm/behaviour.php new file mode 100644 index 00000000000..ef8ac168d31 --- /dev/null +++ b/question/behaviour/immediatecbm/behaviour.php @@ -0,0 +1,151 @@ +. + + +/** + * Question behaviour where the student can submit questions one at a + * time for immediate feedback, with certainty based marking. + * + * @package qbehaviour_immediatecbm + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../immediatefeedback/behaviour.php'); + +/** + * Question behaviour for immediate feedback with CBM. + * + * Each question has a submit button next to it along with some radio buttons + * to input a certainly, that is, how sure they are that they are right. + * The student can submit their answer at any time for immediate feedback. + * Once the qustion is submitted, it is not possible for the student to change + * their answer any more. The student's degree of certainly affects their score. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_immediatecbm extends qbehaviour_immediatefeedback { + const IS_ARCHETYPAL = true; + + public function get_min_fraction() { + return question_cbm::adjust_fraction(parent::get_min_fraction(), question_cbm::HIGH); + } + + public function get_expected_data() { + if ($this->qa->get_state()->is_active()) { + return array( + 'submit' => PARAM_BOOL, + 'certainty' => PARAM_INT, + ); + } + return parent::get_expected_data(); + } + + public function get_right_answer_summary() { + $summary = parent::get_right_answer_summary(); + return question_cbm::summary_with_certainty($summary, question_cbm::HIGH); + } + + public function get_correct_response() { + if ($this->qa->get_state()->is_active()) { + return array('certainty' => question_cbm::HIGH); + } + return array(); + } + + protected function get_our_resume_data() { + $lastcertainty = $this->qa->get_last_behaviour_var('certainty'); + if ($lastcertainty) { + return array('-certainty' => $lastcertainty); + } else { + return array(); + } + } + + protected function is_same_response($pendingstep) { + return parent::is_same_response($pendingstep) && + $this->qa->get_last_behaviour_var('certainty') == $pendingstep->get_behaviour_var('certainty'); + } + + protected function is_complete_response($pendingstep) { + return parent::is_complete_response($pendingstep) && $pendingstep->has_behaviour_var('certainty'); + } + + public function process_submit(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + if (!$this->qa->get_question()->is_gradable_response($pendingstep->get_qt_data()) || + !$pendingstep->has_behaviour_var('certainty')) { + $pendingstep->set_state(question_state::$invalid); + return question_attempt::KEEP; + } + + return $this->do_grading($pendingstep, $pendingstep); + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + $laststep = $this->qa->get_last_step(); + return $this->do_grading($laststep, $pendingstep); + } + + protected function do_grading(question_attempt_step $responsesstep, + question_attempt_pending_step $pendingstep) { + if (!$this->question->is_gradable_response($responsesstep->get_qt_data())) { + $pendingstep->set_state(question_state::$gaveup); + + } else { + $response = $responsesstep->get_qt_data(); + list($fraction, $state) = $this->question->grade_response($response); + + if ($responsesstep->has_behaviour_var('certainty')) { + $certainty = $responsesstep->get_behaviour_var('certainty'); + } else { + $certainty = question_cbm::default_certainty(); + $pendingstep->set_behaviour_var('_assumedcertainty', $certainty); + } + + $pendingstep->set_behaviour_var('_rawfraction', $fraction); + $pendingstep->set_fraction(question_cbm::adjust_fraction($fraction, $certainty)); + $pendingstep->set_state($state); + $pendingstep->set_new_response_summary( + question_cbm::summary_with_certainty( + $this->question->summarise_response($response), $responsesstep->get_behaviour_var('certainty'))); + } + return question_attempt::KEEP; + } + + public function summarise_action(question_attempt_step $step) { + $summary = parent::summarise_action($step); + if ($step->has_behaviour_var('certainty')) { + $summary = question_cbm::summary_with_certainty($summary, + $step->get_behaviour_var('certainty')); + } + return $summary; + } + + public static function adjust_random_guess_score($fraction) { + return question_cbm::adjust_fraction($fraction, question_cbm::default_certainty()); + } +} diff --git a/question/behaviour/immediatecbm/lang/en_utf8/qbehaviour_immediatecbm.php b/question/behaviour/immediatecbm/lang/en_utf8/qbehaviour_immediatecbm.php new file mode 100644 index 00000000000..e754515ef13 --- /dev/null +++ b/question/behaviour/immediatecbm/lang/en_utf8/qbehaviour_immediatecbm.php @@ -0,0 +1,4 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the immediate + * feedback with CBM behaviour. + * + * @package qbehaviour_immediatecbm + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../deferredcbm/renderer.php'); + + +class qbehaviour_immediatecbm_renderer extends qbehaviour_deferredcbm_renderer { + public function controls(question_attempt $qa, question_display_options $options) { + $output = parent::controls($qa, $options); + if ($qa->get_state() == question_state::$invalid && !$qa->get_last_step()->has_behaviour_var('certainty')) { + $output .= html_writer::tag('div', + get_string('pleaseselectacertainty', 'qbehaviour_immediatecbm'), + array('class' => 'validationerror')); + } + $output .= $this->submit_button($qa, $options); + return $output; + } +} diff --git a/question/behaviour/immediatecbm/simpletest/testwalkthrough.php b/question/behaviour/immediatecbm/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..425ec4efcfa --- /dev/null +++ b/question/behaviour/immediatecbm/simpletest/testwalkthrough.php @@ -0,0 +1,282 @@ +. + + +/** + * This file contains tests that walks a question through the immediate cbm + * behaviour. + * + * @package qbehaviour_immediatecbm + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_immediatecbm_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_immediatecbm_feedback_multichoice_right() { + + // Create a true-false question with correct answer true. + $mc = test_question_maker::make_a_multichoice_single_question(); + $this->start_attempt_at_question($mc, 'immediatecbm'); + + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + $this->assertEqual('A [' . question_cbm::get_string(question_cbm::HIGH) . ']', + $this->quba->get_right_answer_summary($this->slot)); + $this->assertPattern('/' . preg_quote($mc->questiontext) . '/', + $this->quba->get_question_summary($this->slot)); + $this->assertNull($this->quba->get_response_summary($this->slot)); + + // Save the wrong answer. + $this->process_submission(array('answer' => $wrongindex, '-certainty' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation()); + + // Submit the right answer. + $this->process_submission(array('answer' => $rightindex, '-certainty' => 2, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(2/3); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_correct_expectation()); + $this->assertEqual('A [' . question_cbm::get_string(2) . ']', + $this->quba->get_response_summary($this->slot)); + + $numsteps = $this->get_step_count(); + + // Finish the attempt - should not need to add a new state. + $this->quba->finish_all_questions(); + + // Verify. + $this->assertEqual($numsteps, $this->get_step_count()); + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(2/3); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_correct_expectation()); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 0.5); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(0.5); + $this->check_current_output( + $this->get_contains_partcorrect_expectation(), + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Now change the correct answer to the question, and regrade. + $mc->answers[13]->fraction = -0.33333333; + $mc->answers[15]->fraction = 1; + $this->quba->regrade_all_questions(); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(0.5); + $this->check_current_output( + $this->get_contains_partcorrect_expectation()); + + $autogradedstep = $this->get_step($this->get_step_count() - 2); + $this->assertWithinMargin($autogradedstep->get_fraction(), -10/9, 0.0000001); + } + + public function test_immediatecbm_feedback_multichoice_try_to_submit_blank() { + + // Create a true-false question with correct answer true. + $mc = test_question_maker::make_a_multichoice_single_question(); + $this->start_attempt_at_question($mc, 'immediatecbm'); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Submit nothing. + $this->process_submission(array('-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_contains_validation_error_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gaveup); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation(0, false, false), + $this->get_contains_mc_radio_expectation(1, false, false), + $this->get_contains_mc_radio_expectation(2, false, false)); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 0.5); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(0.5); + $this->check_current_output( + $this->get_contains_partcorrect_expectation(), + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + } + + public function test_immediatecbm_feedback_shortanswer_try_to_submit_no_certainty() { + + // Create a short answer question with correct answer true. + $sa = test_question_maker::make_a_shortanswer_question(); + $this->start_attempt_at_question($sa, 'immediatecbm'); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Submit with certainty missing. + $this->process_submission(array('-submit' => 1, 'answer' => 'frog')); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_contains_validation_error_expectation()); + + // Now get it right. + $this->process_submission(array('-submit' => 1, 'answer' => 'frog', '-certainty' => 3)); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_does_not_contain_validation_error_expectation()); + } + + public function test_immediatecbm_feedback_multichoice_wrong_on_finish() { + + // Create a true-false question with correct answer true. + $mc = test_question_maker::make_a_multichoice_single_question(); + $this->start_attempt_at_question($mc, 'immediatecbm'); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + + // Save the wrong answer. + $this->process_submission(array('answer' => $wrongindex, '-certainty' => 3)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedwrong); + $this->check_current_mark(-3); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, false, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_incorrect_expectation()); + } + + public function test_immediatecbm_cbm_truefalse_no_certainty_feedback_when_not_answered() { + + // Create a true-false question with correct answer true. + $tf = test_question_maker::make_a_truefalse_question(); + $this->start_attempt_at_question($tf, 'deferredcbm', 2); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_does_not_contain_correctness_expectation(), + $this->get_contains_cbm_radio_expectation(1, true, false), + $this->get_does_not_contain_feedback_expectation()); + + // Finish without answering. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gaveup); + $this->check_current_mark(null); + $this->check_current_output( + new NoPatternExpectation('/class=\"im-feedback/')); + } +} diff --git a/question/behaviour/immediatefeedback/behaviour.php b/question/behaviour/immediatefeedback/behaviour.php new file mode 100644 index 00000000000..cf7b4c338f7 --- /dev/null +++ b/question/behaviour/immediatefeedback/behaviour.php @@ -0,0 +1,131 @@ +. + + +/** + * Question behaviour where the student can submit questions one at a + * time for immediate feedback. + * + * @package qbehaviour_immediatefeedback + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Question behaviour for immediate feedback. + * + * Each question has a submit button next to it which the student can use to + * submit it. Once the qustion is submitted, it is not possible for the + * student to change their answer any more, but the student gets full feedback + * straight away. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_immediatefeedback extends question_behaviour_with_save { + const IS_ARCHETYPAL = true; + + public function required_question_definition_type() { + return 'question_automatically_gradable'; + } + + public function get_min_fraction() { + return $this->question->get_min_fraction(); + } + + public function get_expected_data() { + if ($this->qa->get_state()->is_active()) { + return array( + 'submit' => PARAM_BOOL, + ); + } + return parent::get_expected_data(); + } + + public function get_right_answer_summary() { + return $this->question->get_right_answer_summary(); + } + + public function process_action(question_attempt_pending_step $pendingstep) { + if ($pendingstep->has_behaviour_var('comment')) { + return $this->process_comment($pendingstep); + } else if ($pendingstep->has_behaviour_var('submit')) { + return $this->process_submit($pendingstep); + } else if ($pendingstep->has_behaviour_var('finish')) { + return $this->process_finish($pendingstep); + } else { + return $this->process_save($pendingstep); + } + } + + public function summarise_action(question_attempt_step $step) { + if ($step->has_behaviour_var('comment')) { + return $this->summarise_manual_comment($step); + } else if ($step->has_behaviour_var('finish')) { + return $this->summarise_finish($step); + } else if ($step->has_behaviour_var('submit')) { + return $this->summarise_submit($step); + } else { + return $this->summarise_save($step); + } + } + + public function process_submit(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + if (!$this->is_complete_response($pendingstep)) { + $pendingstep->set_state(question_state::$invalid); + + } else { + $response = $pendingstep->get_qt_data(); + list($fraction, $state) = $this->question->grade_response($response); + $pendingstep->set_fraction($fraction); + $pendingstep->set_state($state); + $pendingstep->set_new_response_summary($this->question->summarise_response($response)); + } + return question_attempt::KEEP; + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + $response = $this->qa->get_last_step()->get_qt_data(); + if (!$this->question->is_gradable_response($response)) { + $pendingstep->set_state(question_state::$gaveup); + + } else { + list($fraction, $state) = $this->question->grade_response($response); + $pendingstep->set_fraction($fraction); + $pendingstep->set_state($state); + } + $pendingstep->set_new_response_summary($this->question->summarise_response($response)); + return question_attempt::KEEP; + } + + public function process_save(question_attempt_pending_step $pendingstep) { + $status = parent::process_save($pendingstep); + if ($status == question_attempt::KEEP && $pendingstep->get_state() == question_state::$complete) { + $pendingstep->set_state(question_state::$todo); + } + return $status; + } +} diff --git a/question/behaviour/immediatefeedback/lang/en_utf8/qbehaviour_immediatefeedback.php b/question/behaviour/immediatefeedback/lang/en_utf8/qbehaviour_immediatefeedback.php new file mode 100644 index 00000000000..2aed0db22e8 --- /dev/null +++ b/question/behaviour/immediatefeedback/lang/en_utf8/qbehaviour_immediatefeedback.php @@ -0,0 +1,3 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the immediate + * feedback behaviour. + * + * @package qbehaviour_immediatefeedback + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +class qbehaviour_immediatefeedback_renderer extends qbehaviour_renderer { + public function controls(question_attempt $qa, question_display_options $options) { + return $this->submit_button($qa, $options); + } +} diff --git a/question/behaviour/immediatefeedback/simpletest/testwalkthrough.php b/question/behaviour/immediatefeedback/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..6811eaf8c05 --- /dev/null +++ b/question/behaviour/immediatefeedback/simpletest/testwalkthrough.php @@ -0,0 +1,237 @@ +. + + +/** + * This file contains tests that walks a question through the immediate feedback + * behaviour. + * + * @package qbehaviour_immediatefeedback + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_immediatefeedback_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_immediatefeedback_feedback_multichoice_right() { + + // Create a true-false question with correct answer true. + $mc = test_question_maker::make_a_multichoice_single_question(); + $this->start_attempt_at_question($mc, 'immediatefeedback'); + + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Save the wrong answer. + $this->process_submission(array('answer' => $wrongindex)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation()); + + // Submit the right answer. + $this->process_submission(array('answer' => $rightindex, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_correct_expectation()); + $this->assertEqual('A', + $this->quba->get_response_summary($this->slot)); + + $numsteps = $this->get_step_count(); + + // Now try to save again - as if the user clicked next in the quiz. + $this->process_submission(array('answer' => $rightindex)); + + // Verify. + $this->assertEqual($numsteps, $this->get_step_count()); + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_correct_expectation()); + + // Finish the attempt - should not need to add a new state. + $this->quba->finish_all_questions(); + + // Verify. + $this->assertEqual($numsteps, $this->get_step_count()); + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_correct_expectation()); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 0.5); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(0.5); + $this->check_current_output( + $this->get_contains_partcorrect_expectation(), + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Now change the correct answer to the question, and regrade. + $mc->answers[13]->fraction = -0.33333333; + $mc->answers[15]->fraction = 1; + $this->quba->regrade_all_questions(); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(0.5); + $this->check_current_output( + $this->get_contains_partcorrect_expectation()); + + $autogradedstep = $this->get_step($this->get_step_count() - 2); + $this->assertWithinMargin($autogradedstep->get_fraction(), -0.3333333, 0.0000001); + } + + public function test_immediatefeedback_feedback_multichoice_try_to_submit_blank() { + + // Create a true-false question with correct answer true. + $mc = test_question_maker::make_a_multichoice_single_question(); + $this->start_attempt_at_question($mc, 'immediatefeedback'); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + // Submit nothing. + $this->process_submission(array('-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation(), + $this->get_contains_validation_error_expectation()); + $this->assertNull($this->quba->get_response_summary($this->slot)); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gaveup); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation(0, false, false), + $this->get_contains_mc_radio_expectation(1, false, false), + $this->get_contains_mc_radio_expectation(2, false, false)); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 0.5); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(0.5); + $this->check_current_output( + $this->get_contains_partcorrect_expectation(), + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + } + + public function test_immediatefeedback_feedback_multichoice_wrong_on_finish() { + + // Create a true-false question with correct answer true. + $mc = test_question_maker::make_a_multichoice_single_question(); + $this->start_attempt_at_question($mc, 'immediatefeedback'); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation()); + + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + + // Save the wrong answer. + $this->process_submission(array('answer' => $wrongindex)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedwrong); + $this->check_current_mark(-0.3333333); + $this->check_current_output( + $this->get_contains_mc_radio_expectation($wrongindex, false, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_incorrect_expectation()); + $this->assertPattern('/B|C/', + $this->quba->get_response_summary($this->slot)); + } +} diff --git a/question/behaviour/informationitem/behaviour.php b/question/behaviour/informationitem/behaviour.php new file mode 100644 index 00000000000..a5b3c4c5595 --- /dev/null +++ b/question/behaviour/informationitem/behaviour.php @@ -0,0 +1,114 @@ +. + + +/** + * This behaviour is for informaiton items. + * + * @package qbehaviour_informationitem + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Question behaviour informaiton items. + * + * For example for the 'Description' 'Question type'. There is no grade, + * and the question type is marked complete the first time the user navigates + * away from a page that contains that question. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_informationitem extends question_behaviour { + + public function required_question_definition_type() { + return 'question_definition'; + } + + public function get_expected_data() { + if ($this->qa->get_state() == question_state::$todo) { + return array('seen' => PARAM_BOOL); + } + return parent::get_expected_data(); + } + + public function get_correct_response() { + if ($this->qa->get_state() == question_state::$todo) { + return array('seen' => 1); + } + return array(); + } + + public function adjust_display_options(question_display_options $options) { + parent::adjust_display_options($options); + + $options->marks = question_display_options::HIDDEN; + + // At the moment, the code exists to process a manual comment on an + // information item, but we don't display the UI unless there is already + // a comment. + if (!$this->qa->get_state()->is_commented()) { + $options->manualcomment = question_display_options::HIDDEN; + } + } + + public function get_state_string($showcorrectness) { + return ''; + } + + public function process_action(question_attempt_pending_step $pendingstep) { + if ($pendingstep->has_behaviour_var('comment')) { + return $this->process_comment($pendingstep); + } else if ($pendingstep->has_behaviour_var('finish')) { + return $this->process_finish($pendingstep); + } else if ($pendingstep->has_behaviour_var('seen')) { + return $this->process_seen($pendingstep); + } else { + return question_attempt::DISCARD; + } + } + + public function summarise_action(question_attempt_step $step) { + if ($step->has_behaviour_var('comment')) { + return $this->summarise_manual_comment($step); + } else if ($step->has_behaviour_var('finish')) { + return $this->summarise_finish($step); + } else if ($step->has_behaviour_var('seen')) { + return get_string('seen', 'qbehaviour_informationitem'); + } + return $this->summarise_start($step); + } + + public function process_comment(question_attempt_pending_step $pendingstep) { + if ($pendingstep->has_behaviour_var('mark')) { + throw new Exception('Information items cannot be graded.'); + } + return parent::process_comment($pendingstep); + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + $pendingstep->set_state(question_state::$finished); + return question_attempt::KEEP; + } + + public function process_seen(question_attempt_pending_step $pendingstep) { + $pendingstep->set_state(question_state::$complete); + return question_attempt::KEEP; + } +} diff --git a/question/behaviour/informationitem/lang/en_utf8/qbehaviour_informationitem.php b/question/behaviour/informationitem/lang/en_utf8/qbehaviour_informationitem.php new file mode 100644 index 00000000000..a14712346dc --- /dev/null +++ b/question/behaviour/informationitem/lang/en_utf8/qbehaviour_informationitem.php @@ -0,0 +1,4 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the information + * item behaviour. + * + * @package qbehaviour_deferredfeedback + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +class qbehaviour_informationitem_renderer extends qbehaviour_renderer { + public function controls(question_attempt $qa, question_display_options $options) { + if ($qa->get_state() != question_state::$todo) { + return ''; + } + + // Hidden input to move the question into the complete state. + return html_writer::empty_tag('input', array( + 'type' => 'hidden', + 'name' => $qa->get_behaviour_field_name('seen'), + 'value' => 1, + )); + } +} diff --git a/question/behaviour/informationitem/simpletest/testwalkthrough.php b/question/behaviour/informationitem/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..bc01852beed --- /dev/null +++ b/question/behaviour/informationitem/simpletest/testwalkthrough.php @@ -0,0 +1,78 @@ +. + + +/** + * This file contains tests that walks a question through the information item + * behaviour. + * + * @package qbehaviour_informationitem + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_informationitem_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_informationitem_feedback_description() { + + // Create a true-false question with correct answer true. + $description = test_question_maker::make_a_description_question(); + $this->start_attempt_at_question($description, 'deferredfeedback'); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output($this->get_contains_question_text_expectation($description), + new ContainsTagWithAttributes('input', array('type' => 'hidden', + 'name' => $this->quba->get_field_prefix($this->slot) . '-seen', 'value' => 1)), + $this->get_does_not_contain_feedback_expectation()); + + // Process a submission indicating this question has been seen. + $this->process_submission(array('-seen' => 1)); + + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + $this->check_current_output($this->get_does_not_contain_correctness_expectation(), + new NoPatternExpectation('/type=\"hidden\"[^>]*name=\"[^"]*seen\"|name=\"[^"]*seen\"[^>]*type=\"hidden\"/'), + $this->get_does_not_contain_feedback_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$finished); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($description), + $this->get_contains_general_feedback_expectation($description)); + + // Process a manual comment. + $this->manual_grade('Not good enough!', null); + + $this->check_current_state(question_state::$manfinished); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Check that trying to process a manual comment with a grade causes an exception. + $this->expectException(); + $this->manual_grade('Not good enough!', 1); + } +} diff --git a/question/behaviour/interactive/behaviour.php b/question/behaviour/interactive/behaviour.php new file mode 100644 index 00000000000..d9328d2d010 --- /dev/null +++ b/question/behaviour/interactive/behaviour.php @@ -0,0 +1,243 @@ +. + + +/** + * Question behaviour where the student can submit questions one at a + * time for immediate feedback. + * + * @package qbehaviour_interactive + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Question behaviour for the interactive model. + * + * Each question has a submit button next to it which the student can use to + * submit it. Once the qustion is submitted, it is not possible for the + * student to change their answer any more, but the student gets full feedback + * straight away. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_interactive extends question_behaviour_with_save { + const IS_ARCHETYPAL = true; + + /** + * Special value used for {@link question_display_options::$readonly when + * we are showing the try again button to the student during an attempt. + * The particular number was chosen randomly. PHP will treat it the same + * as true, but in the renderer we reconginse it display the try again + * button enabled even though the rest of the question is disabled. + * @var integer + */ + const READONLY_EXCEPT_TRY_AGAIN = 23485299; + + public function required_question_definition_type() { + return 'question_automatically_gradable'; + } + + public function get_right_answer_summary() { + return $this->question->get_right_answer_summary(); + } + + /** + * @return boolean are we are currently in the try_again state. + */ + protected function is_try_again_state() { + $laststep = $this->qa->get_last_step(); + return $this->qa->get_state()->is_active() && + $laststep->has_behaviour_var('submit') && $laststep->has_behaviour_var('_triesleft'); + } + + public function adjust_display_options(question_display_options $options) { + // We only need different behaviour in try again states. + if (!$this->is_try_again_state()) { + parent::adjust_display_options($options); + return; + } + + // Let the hint adjust the options. + $hint = $this->get_applicable_hint(); + if (!is_null($hint)) { + $hint->adjust_display_options($options); + } + + // Now call the base class method, but protect some fields from being overwritten. + $save = clone($options); + parent::adjust_display_options($options); + $options->feedback = $save->feedback; + $options->numpartscorrect = $save->numpartscorrect; + + // In a try-again state, everything except the try again button + // Should be read-only. This is a mild hack to achieve this. + if (!$options->readonly) { + $options->readonly = self::READONLY_EXCEPT_TRY_AGAIN; + } + } + + public function get_applicable_hint() { + if (!$this->is_try_again_state()) { + return null; + } + return $this->question->get_hint(count($this->question->hints) - + $this->qa->get_last_behaviour_var('_triesleft'), $this->qa); + } + + public function get_expected_data() { + if ($this->is_try_again_state()) { + return array( + 'tryagain' => PARAM_BOOL, + ); + } else if ($this->qa->get_state()->is_active()) { + return array( + 'submit' => PARAM_BOOL, + ); + } + return parent::get_expected_data(); + } + + public function get_expected_qt_data() { + $hint = $this->get_applicable_hint(); + if (!empty($hint->clearwrong)) { + return $this->question->get_expected_data(); + } + return parent::get_expected_qt_data(); + } + + public function get_state_string($showcorrectness) { + $state = $this->qa->get_state(); + if (!$state->is_active() || $state == question_state::$invalid) { + return parent::get_state_string($showcorrectness); + } + + if ($this->is_try_again_state()) { + return get_string('notcomplete', 'qbehaviour_interactive'); + } else { + return get_string('triesremaining', 'qbehaviour_interactive', + $this->qa->get_last_behaviour_var('_triesleft')); + } + } + + public function init_first_step(question_attempt_step $step) { + parent::init_first_step($step); + $step->set_behaviour_var('_triesleft', count($this->question->hints) + 1); + } + + public function process_action(question_attempt_pending_step $pendingstep) { + if ($pendingstep->has_behaviour_var('finish')) { + return $this->process_finish($pendingstep); + } + if ($this->is_try_again_state()) { + if ($pendingstep->has_behaviour_var('tryagain')) { + return $this->process_try_again($pendingstep); + } else { + return question_attempt::DISCARD; + } + } else { + if ($pendingstep->has_behaviour_var('comment')) { + return $this->process_comment($pendingstep); + } else if ($pendingstep->has_behaviour_var('submit')) { + return $this->process_submit($pendingstep); + } else { + return $this->process_save($pendingstep); + } + } + } + + public function summarise_action(question_attempt_step $step) { + if ($step->has_behaviour_var('comment')) { + return $this->summarise_manual_comment($step); + } else if ($step->has_behaviour_var('finish')) { + return $this->summarise_finish($step); + } else if ($step->has_behaviour_var('tryagain')) { + return get_string('tryagain', 'qbehaviour_interactive'); + } else if ($step->has_behaviour_var('submit')) { + return $this->summarise_submit($step); + } else { + return $this->summarise_save($step); + } + } + + public function process_try_again(question_attempt_pending_step $pendingstep) { + $pendingstep->set_state(question_state::$todo); + return question_attempt::KEEP; + } + + public function process_submit(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + if (!$this->is_complete_response($pendingstep)) { + $pendingstep->set_state(question_state::$invalid); + + } else { + $triesleft = $this->qa->get_last_behaviour_var('_triesleft'); + $response = $pendingstep->get_qt_data(); + list($fraction, $state) = $this->question->grade_response($response); + if ($state == question_state::$gradedright || $triesleft == 1) { + $pendingstep->set_state($state); + $pendingstep->set_fraction($this->adjust_fraction($fraction, $pendingstep)); + + } else { + $pendingstep->set_behaviour_var('_triesleft', $triesleft - 1); + $pendingstep->set_state(question_state::$todo); + } + $pendingstep->set_new_response_summary($this->question->summarise_response($response)); + } + return question_attempt::KEEP; + } + + protected function adjust_fraction($fraction, question_attempt_pending_step $pendingstep) { + $totaltries = $this->qa->get_step(0)->get_behaviour_var('_triesleft'); + $triesleft = $this->qa->get_last_behaviour_var('_triesleft'); + + $fraction -= ($totaltries - $triesleft) * $this->question->penalty; + $fraction = max($fraction, 0); + return $fraction; + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + $response = $this->qa->get_last_qt_data(); + if (!$this->question->is_gradable_response($response)) { + $pendingstep->set_state(question_state::$gaveup); + + } else { + list($fraction, $state) = $this->question->grade_response($response); + $pendingstep->set_fraction($this->adjust_fraction($fraction, $pendingstep)); + $pendingstep->set_state($state); + } + $pendingstep->set_new_response_summary($this->question->summarise_response($response)); + return question_attempt::KEEP; + } + + public function process_save(question_attempt_pending_step $pendingstep) { + $status = parent::process_save($pendingstep); + if ($status == question_attempt::KEEP && $pendingstep->get_state() == question_state::$complete) { + $pendingstep->set_state(question_state::$todo); + } + return $status; + } +} diff --git a/question/behaviour/interactive/lang/en_utf8/qbehaviour_interactive.php b/question/behaviour/interactive/lang/en_utf8/qbehaviour_interactive.php new file mode 100644 index 00000000000..63a34c503f8 --- /dev/null +++ b/question/behaviour/interactive/lang/en_utf8/qbehaviour_interactive.php @@ -0,0 +1,6 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the interactive + * behaviour. + * + * @package qbehaviour_interactive + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Interactive behaviour renderer. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_interactive_renderer extends qbehaviour_renderer { + public function controls(question_attempt $qa, question_display_options $options) { + return $this->submit_button($qa, $options); + } + + public function feedback(question_attempt $qa, question_display_options $options) { + if (!$qa->get_state()->is_active() || !$options->readonly) { + return ''; + } + + $attributes = array( + 'type' => 'submit', + 'id' => $qa->get_behaviour_field_name('tryagain'), + 'name' => $qa->get_behaviour_field_name('tryagain'), + 'value' => get_string('tryagain', 'qbehaviour_interactive'), + 'class' => 'submit btn', + ); + if ($options->readonly !== qbehaviour_interactive::READONLY_EXCEPT_TRY_AGAIN) { + $attributes['disabled'] = 'disabled'; + } + $output = html_writer::empty_tag('input', $attributes); + if (empty($attributes['disabled'])) { + $output .= print_js_call('question_init_submit_button', + array($attributes['id'], $qa->get_slot()), true); + } + return $output; + } +} diff --git a/question/behaviour/interactive/simpletest/testwalkthrough.php b/question/behaviour/interactive/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..f6fc9cb43f2 --- /dev/null +++ b/question/behaviour/interactive/simpletest/testwalkthrough.php @@ -0,0 +1,470 @@ +. + + +/** + * This file contains tests that walks a question through the interactive + * behaviour. + * + * @package qbehaviour_interactive + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_interactive_walkthrough_test extends qbehaviour_walkthrough_test_base { + + public function test_interactive_feedback_multichoice_right() { + + // Create a multichoice single question. + $mc = test_question_maker::make_a_multichoice_single_question(); + $mc->hints = array( + new question_hint_with_parts('This is the first hint.', false, false), + new question_hint_with_parts('This is the second hint.', true, true), + ); + $this->start_attempt_at_question($mc, 'interactive'); + + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(3), + $this->get_no_hint_visible_expectation()); + + // Save the wrong answer. + $this->process_submission(array('answer' => $wrongindex)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(3), + $this->get_no_hint_visible_expectation()); + + // Submit the wrong answer. + $this->process_submission(array('answer' => $wrongindex, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_mc_radio_expectation($wrongindex, false, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_try_again_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'), + $this->get_contains_hint_expectation('This is the first hint')); + + // Check that, if we review in this state, the try again button is disabled. + $displayoptions = new question_display_options(); + $displayoptions->readonly = true; + $html = $this->quba->render_question($this->slot, $displayoptions); + $this->assert($this->get_contains_try_again_button_expectation(false), $html); + + // Do try again. + $this->process_submission(array('-tryagain' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_mc_radio_expectation($wrongindex, true, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(2), + $this->get_no_hint_visible_expectation()); + + // Submit the right answer. + $this->process_submission(array('answer' => $rightindex, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(0.6666667); + $this->check_current_output( + $this->get_contains_mark_summary(0.6666667), + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_correct_expectation(), + $this->get_no_hint_visible_expectation()); + + // Finish the attempt - should not need to add a new state. + $numsteps = $this->get_step_count(); + $this->quba->finish_all_questions(); + + // Verify. + $this->assertEqual($numsteps, $this->get_step_count()); + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(0.6666667); + $this->check_current_output( + $this->get_contains_mark_summary(0.6666667), + $this->get_contains_mc_radio_expectation($rightindex, false, true), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false), + $this->get_contains_correct_expectation(), + $this->get_no_hint_visible_expectation()); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 0.5); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(0.5); + $this->check_current_output( + $this->get_contains_mark_summary(0.5), + $this->get_contains_partcorrect_expectation(), + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Check regrading does not mess anything up. + $this->quba->regrade_all_questions(); + + // Verify. + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(0.5); + $this->check_current_output( + $this->get_contains_mark_summary(0.5), + $this->get_contains_partcorrect_expectation()); + + $autogradedstep = $this->get_step($this->get_step_count() - 2); + $this->assertWithinMargin($autogradedstep->get_fraction(), 0.6666667, 0.0000001); + } + + public function test_interactive_finish_when_try_again_showing() { + + // Create a multichoice single question. + $mc = test_question_maker::make_a_multichoice_single_question(); + $mc->hints = array( + new question_hint_with_parts('This is the first hint.', false, false), + ); + $this->start_attempt_at_question($mc, 'interactive'); + + $rightindex = $this->get_mc_right_answer_index($mc); + $wrongindex = ($rightindex + 1) % 3; + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_radio_expectation(0, true, false), + $this->get_contains_mc_radio_expectation(1, true, false), + $this->get_contains_mc_radio_expectation(2, true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(2), + $this->get_no_hint_visible_expectation(), + new PatternExpectation('/' . preg_quote(get_string('selectone', 'qtype_multichoice'), '/') . '/')); + + // Submit the wrong answer. + $this->process_submission(array('answer' => $wrongindex, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_mc_radio_expectation($wrongindex, false, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_try_again_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'), + $this->get_contains_hint_expectation('This is the first hint')); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedwrong); + $this->check_current_mark(0); + $this->check_current_output( + $this->get_contains_mark_summary(0), + $this->get_contains_mc_radio_expectation($wrongindex, false, true), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false), + $this->get_contains_incorrect_expectation(), + $this->get_no_hint_visible_expectation()); + } + + public function test_interactive_shortanswer_try_to_submit_blank() { + + // Create a short answer question. + $sa = test_question_maker::make_a_shortanswer_question(); + $sa->hints = array( + new question_hint('This is the first hint.'), + new question_hint('This is the second hint.'), + ); + $this->start_attempt_at_question($sa, 'interactive'); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_does_not_contain_validation_error_expectation(), + $this->get_does_not_contain_try_again_button_expectation(), + $this->get_no_hint_visible_expectation()); + + // Submit blank. + $this->process_submission(array('-submit' => 1, 'answer' => '')); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_contains_validation_error_expectation(), + $this->get_does_not_contain_try_again_button_expectation(), + $this->get_no_hint_visible_expectation()); + + // Now get it wrong. + $this->process_submission(array('-submit' => 1, 'answer' => 'newt')); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_submit_button_expectation(false), + $this->get_does_not_contain_validation_error_expectation(), + $this->get_contains_try_again_button_expectation(true), + new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'), + $this->get_contains_hint_expectation('This is the first hint')); + $this->assertEqual('newt', + $this->quba->get_response_summary($this->slot)); + + // Try again. + $this->process_submission(array('-tryagain' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_does_not_contain_validation_error_expectation(), + $this->get_does_not_contain_try_again_button_expectation(), + $this->get_no_hint_visible_expectation()); + + // Now submit blank again. + $this->process_submission(array('-submit' => 1, 'answer' => '')); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_contains_validation_error_expectation(), + $this->get_does_not_contain_try_again_button_expectation(), + $this->get_no_hint_visible_expectation()); + + // Now get it right. + $this->process_submission(array('-submit' => 1, 'answer' => 'frog')); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(0.6666667); + $this->check_current_output( + $this->get_contains_mark_summary(0.6666667), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_correct_expectation(), + $this->get_does_not_contain_validation_error_expectation(), + $this->get_no_hint_visible_expectation()); + $this->assertEqual('frog', + $this->quba->get_response_summary($this->slot)); + } + + public function test_interactive_feedback_multichoice_multiple_reset() { + + // Create a multichoice multiple question. + $mc = test_question_maker::make_a_multichoice_multi_question(); + $mc->hints = array( + new question_hint_with_parts('This is the first hint.', true, true), + new question_hint_with_parts('This is the second hint.', true, true), + ); + $this->start_attempt_at_question($mc, 'interactive', 2); + + $right = array_keys($mc->get_correct_response()); + $wrong = array_diff(array('choice0', 'choice1', 'choice2', 'choice3'), $right); + $wrong = array_values(array_diff(array('choice0', 'choice1', 'choice2', 'choice3'), $right)); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_question_text_expectation($mc), + $this->get_contains_mc_checkbox_expectation('choice0', true, false), + $this->get_contains_mc_checkbox_expectation('choice1', true, false), + $this->get_contains_mc_checkbox_expectation('choice2', true, false), + $this->get_contains_mc_checkbox_expectation('choice3', true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_does_not_contain_num_parts_correct(), + $this->get_tries_remaining_expectation(3), + $this->get_no_hint_visible_expectation(), + new PatternExpectation('/' . preg_quote(get_string('selectmulti', 'qtype_multichoice'), '/') . '/')); + + // Submit an answer with one right, and one wrong. + $this->process_submission(array($right[0] => 1, $wrong[0] => 1, '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_mc_checkbox_expectation($right[0], false, true), + $this->get_contains_mc_checkbox_expectation($right[1], false, false), + $this->get_contains_mc_checkbox_expectation($wrong[0], false, true), + $this->get_contains_mc_checkbox_expectation($wrong[1], false, false), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_try_again_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'), + $this->get_contains_hint_expectation('This is the first hint'), + $this->get_contains_num_parts_correct(1), + $this->get_contains_standard_incorrect_combined_feedback_expectation(), + $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . $right[0], '1'), + $this->get_does_not_contain_hidden_expectation($this->quba->get_field_prefix($this->slot) . $right[1]), + $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . $wrong[0], '0'), + $this->get_does_not_contain_hidden_expectation($this->quba->get_field_prefix($this->slot) . $wrong[1])); + + // Do try again. + $this->process_submission(array($right[0] => 1, '-tryagain' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_marked_out_of_summary(), + $this->get_contains_mc_checkbox_expectation($right[0], true, true), + $this->get_contains_mc_checkbox_expectation($right[1], true, false), + $this->get_contains_mc_checkbox_expectation($wrong[0], true, false), + $this->get_contains_mc_checkbox_expectation($wrong[1], true, false), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(2), + $this->get_no_hint_visible_expectation()); + } + + public function test_interactive_regrade_changing_num_tries_leaving_open() { + // Create a multichoice multiple question. + $q = test_question_maker::make_a_shortanswer_question(); + $q->hints = array( + new question_hint_with_parts('This is the first hint.', true, true), + new question_hint_with_parts('This is the second hint.', true, true), + ); + $this->start_attempt_at_question($q, 'interactive', 3); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_tries_remaining_expectation(3)); + + // Submit the right answer. + $this->process_submission(array('answer' => 'frog', '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(3); + + // Now change the quiestion so that answer is only partially right, and regrade. + $q->answers[0]->fraction = 0.6666667; + $q->answers[1]->fraction = 1; + + $this->quba->regrade_all_questions(false); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + } + + public function test_interactive_regrade_changing_num_tries_finished() { + // Create a multichoice multiple question. + $q = test_question_maker::make_a_shortanswer_question(); + $q->hints = array( + new question_hint_with_parts('This is the first hint.', true, true), + new question_hint_with_parts('This is the second hint.', true, true), + ); + $this->start_attempt_at_question($q, 'interactive', 3); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_tries_remaining_expectation(3)); + + // Submit the right answer. + $this->process_submission(array('answer' => 'frog', '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(3); + + // Now change the quiestion so that answer is only partially right, and regrade. + $q->answers[0]->fraction = 0.6666667; + $q->answers[1]->fraction = 1; + + $this->quba->regrade_all_questions(true); + + // Verify. + $this->check_current_state(question_state::$gradedpartial); + // TODO I don't think 1 is the right fraction here. However, it is what + // you get attempting a question like this without regrading being involved, + // and I am currently interested in testing regrading here. + $this->check_current_mark(1); + } +} diff --git a/question/behaviour/interactivecountback/behaviour.php b/question/behaviour/interactivecountback/behaviour.php new file mode 100644 index 00000000000..8a54839d1f6 --- /dev/null +++ b/question/behaviour/interactivecountback/behaviour.php @@ -0,0 +1,91 @@ +. + + +/** + * Question behaviour that is like the interactive behaviour, but where the + * student is credited for parts of the question they got right on earlier tries. + * + * @package qbehaviour_interactivecountback + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../interactive/behaviour.php'); + +/** + * Question behaviour for interactive mode with count-back scoring. + * + * As an example, suppose we have a matching question with 4 parts, and 3 tries + * (penalty 1/3), and the question is worth 12 marks (so, 3 marks for each part). + * Suppose also that: + * - on the first try, the student gets the first two parts right, and the + * other two wrong. + * - on the second try, they are sure they got the first part right, so keep + * their answer the same, but they change their answer to the second part. + * They also get the answer to the thrid part right on this try, but still + * get the 4th part wrong. + * - On the final try, they get the first 3 parts right, but the 4th part still + * wrong. + * We want to grade them as follows. + * - For the first part, they were right first time, and did not change their + * answer, so we credit that part as right first time: 3/3 + * - For the second part, although they were right first time, they then changed + * their mind, an only finally got it right on the third try, so 1/3. + * - For the third part, they got it right on the second try, and then did not + * change their answer, so 2/3. + * - For the last part, they were wrong at the last try, so 0/3. + * So, total mark is 6/12. (Really, a fraction of 0.5.) + * + * Of course, the details of the grading are acutally up to the particular + * question type. The point is that the final grade can take into account all + * of the tries the student made. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_interactivecountback extends qbehaviour_interactive { + const IS_ARCHETYPAL = false; + + public function required_question_definition_type() { + return 'question_automatically_gradable_with_countback'; + } + + protected function adjust_fraction($fraction, question_attempt_pending_step $pendingstep) { + $totaltries = $this->qa->get_step(0)->get_behaviour_var('_triesleft'); + + $responses = array(); + $lastsave = array(); + foreach ($this->qa->get_step_iterator() as $step) { + if ($step->has_behaviour_var('submit') && $step->get_state() != question_state::$invalid) { + $responses[] = $step->get_qt_data(); + $lastsave = array(); + } else { + $lastsave = $step->get_qt_data(); + } + } + $lastresponse = $pendingstep->get_qt_data(); + if (!empty($lastresponse)) { + $responses[] = $lastresponse; + } else if (!empty($lastsave)) { + $responses[] = $lastsave; + } + + return $this->question->compute_final_grade($responses, $totaltries); + } +} diff --git a/question/behaviour/interactivecountback/lang/en_utf8/qbehaviour_interactivecountback.php b/question/behaviour/interactivecountback/lang/en_utf8/qbehaviour_interactivecountback.php new file mode 100644 index 00000000000..d8146ea01f3 --- /dev/null +++ b/question/behaviour/interactivecountback/lang/en_utf8/qbehaviour_interactivecountback.php @@ -0,0 +1,3 @@ +. + + +require_once(dirname(__FILE__) . '/../interactive/renderer.php'); + + +/** + * Renderer for outputting parts of a question belonging to the interactive with + * countback behaviour. + * + * There are not differences from the interactive output. We just need a class + * definition. + * + * @package qbehaviour_interactivecountback + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +class qbehaviour_interactivecountback_renderer extends qbehaviour_interactive_renderer { +} \ No newline at end of file diff --git a/question/behaviour/interactivecountback/simpletest/testwalkthrough.php b/question/behaviour/interactivecountback/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..14de088873d --- /dev/null +++ b/question/behaviour/interactivecountback/simpletest/testwalkthrough.php @@ -0,0 +1,135 @@ +. + + +/** + * This file contains tests that walks a question through the interactive with + * countback behaviour. + * + * @package qbehaviour_interactivecountback + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_interactivecountback_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_interactive_feedback_match_reset() { + + // Create a matching question. + $m = test_question_maker::make_a_matching_question(); + $m->shufflestems = false; + $m->hints = array( + new question_hint_with_parts('This is the first hint.', true, true), + new question_hint_with_parts('This is the second hint.', true, true), + ); + $this->start_attempt_at_question($m, 'interactive', 12); + + $choiceorder = $m->get_choice_order(); + $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); + $choices = array(0 => get_string('choose') . '...'); + foreach ($choiceorder as $key => $choice) { + $choices[$key] = $m->choices[$choice]; + } + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->assertEqual('interactivecountback', + $this->quba->get_question_attempt($this->slot)->get_behaviour_name()); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, null, true), + $this->get_contains_select_expectation('sub1', $choices, null, true), + $this->get_contains_select_expectation('sub2', $choices, null, true), + $this->get_contains_select_expectation('sub3', $choices, null, true), + $this->get_contains_question_text_expectation($m), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(3), + $this->get_does_not_contain_num_parts_correct(), + $this->get_no_hint_visible_expectation()); + + // Submit an answer with two right, and two wrong. + $this->process_submission(array('sub0' => $orderforchoice[1], + 'sub1' => $orderforchoice[1], 'sub2' => $orderforchoice[1], + 'sub3' => $orderforchoice[1], '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], false), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[1], false), + $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[1], false), + $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], false), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_try_again_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'), + $this->get_contains_hint_expectation('This is the first hint'), + $this->get_contains_num_parts_correct(2), + $this->get_contains_standard_partiallycorrect_combined_feedback_expectation(), + $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . 'sub0', $orderforchoice[1]), + $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . 'sub1', '0'), + $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . 'sub2', '0'), + $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . 'sub3', $orderforchoice[1])); + + // Check that extract responses will return the reset data. + $prefix = $this->quba->get_field_prefix($this->slot); + $this->assertEqual(array('sub0' => 1), + $this->quba->extract_responses($this->slot, array($prefix . 'sub0' => 1))); + + // Do try again. + $this->process_submission(array('sub0' => $orderforchoice[1], 'sub3' => $orderforchoice[1], '-tryagain' => 1)); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], true), + $this->get_contains_select_expectation('sub1', $choices, null, true), + $this->get_contains_select_expectation('sub2', $choices, null, true), + $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], true), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(2), + $this->get_no_hint_visible_expectation()); + + // Submit the right answer. + $this->process_submission(array('sub0' => $orderforchoice[1], + 'sub1' => $orderforchoice[2], 'sub2' => $orderforchoice[2], + 'sub3' => $orderforchoice[1], '-submit' => 1)); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(10); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], false), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[2], false), + $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[2], false), + $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], false), + $this->get_contains_submit_button_expectation(false), + $this->get_does_not_contain_try_again_button_expectation(), + $this->get_contains_correct_expectation(), + $this->get_contains_standard_correct_combined_feedback_expectation(), + new NoPatternExpectation('/class="control\b[^"]*\bpartiallycorrect"/')); + } +} + diff --git a/question/behaviour/manualgraded/behaviour.php b/question/behaviour/manualgraded/behaviour.php new file mode 100644 index 00000000000..ac81e832c64 --- /dev/null +++ b/question/behaviour/manualgraded/behaviour.php @@ -0,0 +1,92 @@ +. + + +/** + * Question behaviour for questions that can only be graded manually. + * + * @package qbehaviour_manualgraded + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Question behaviour for questions that can only be graded manually. + * + * The student enters their response during the attempt, and it is saved. Later, + * when the whole attempt is finished, the attempt goes into the NEEDS_GRADING + * state, and the teacher must grade it manually. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_manualgraded extends question_behaviour_with_save { + const IS_ARCHETYPAL = true; + + public static function get_unused_display_options() { + return array('correctness', 'marks', 'specificfeedback', 'generalfeedback', + 'rightanswer'); + } + + public function adjust_display_options(question_display_options $options) { + parent::adjust_display_options($options); + + if ($this->qa->get_state()->is_finished()) { + // Hide all feedback except genfeedback and manualcomment. + $save = clone($options); + $options->hide_all_feedback(); + $options->generalfeedback = $save->generalfeedback; + $options->manualcomment = $save->manualcomment; + } + } + + public function process_action(question_attempt_pending_step $pendingstep) { + if ($pendingstep->has_behaviour_var('comment')) { + return $this->process_comment($pendingstep); + } else if ($pendingstep->has_behaviour_var('finish')) { + return $this->process_finish($pendingstep); + } else { + return $this->process_save($pendingstep); + } + } + + public function summarise_action(question_attempt_step $step) { + if ($step->has_behaviour_var('comment')) { + return $this->summarise_manual_comment($step); + } else if ($step->has_behaviour_var('finish')) { + return $this->summarise_finish($step); + } else { + return $this->summarise_save($step); + } + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + $response = $this->qa->get_last_step()->get_qt_data(); + if (!$this->question->is_complete_response($response)) { + $pendingstep->set_state(question_state::$gaveup); + } else { + $pendingstep->set_state(question_state::$needsgrading); + } + $pendingstep->set_new_response_summary($this->question->summarise_response($response)); + return question_attempt::KEEP; + } +} diff --git a/question/behaviour/manualgraded/lang/en_utf8/qbehaviour_manualgraded.php b/question/behaviour/manualgraded/lang/en_utf8/qbehaviour_manualgraded.php new file mode 100644 index 00000000000..d15f8c39f62 --- /dev/null +++ b/question/behaviour/manualgraded/lang/en_utf8/qbehaviour_manualgraded.php @@ -0,0 +1,3 @@ +. + + +/** + * Renderer for outputting parts of a question belonging to the manual + * graded behaviour. + * + * @package qbehaviour_manualgraded + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +class qbehaviour_manualgraded_renderer extends qbehaviour_renderer { + +} \ No newline at end of file diff --git a/question/behaviour/manualgraded/simpletest/testwalkthrough.php b/question/behaviour/manualgraded/simpletest/testwalkthrough.php new file mode 100644 index 00000000000..3221baec8ef --- /dev/null +++ b/question/behaviour/manualgraded/simpletest/testwalkthrough.php @@ -0,0 +1,259 @@ +. + + +/** + * This file contains tests that walks a question through the manual graded + * behaviour. + * + * @package qbehaviour_manualgraded + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); + +class qbehaviour_manualgraded_walkthrough_test extends qbehaviour_walkthrough_test_base { + public function test_manual_graded_essay() { + + // Create an essay question. + $essay = test_question_maker::make_an_essay_question(); + $this->start_attempt_at_question($essay, 'deferredfeedback', 10); + + // Check the right model is being used. + $this->assertEqual('manualgraded', $this->quba-> + get_question_attempt($this->slot)->get_behaviour_name()); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output($this->get_contains_question_text_expectation($essay), + $this->get_does_not_contain_feedback_expectation()); + + // Simulate some data submitted by the student. + $this->process_submission(array('answer' => 'This is my wonderful essay!')); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + $this->check_current_output( + new ContainsTagWithAttribute('textarea', 'name', + $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')), + $this->get_does_not_contain_feedback_expectation()); + + // Process the same data again, check it does not create a new step. + $numsteps = $this->get_step_count(); + $this->process_submission(array('answer' => 'This is my wonderful essay!')); + $this->check_step_count($numsteps); + + // Process different data, check it creates a new step. + $this->process_submission(array('answer' => '')); + $this->check_step_count($numsteps + 1); + $this->check_current_state(question_state::$todo); + + // Change back, check it creates a new step. + $this->process_submission(array('answer' => 'This is my wonderful essay!')); + $this->check_step_count($numsteps + 2); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$needsgrading); + $this->check_current_mark(null); + $this->assertEqual('This is my wonderful essay!', + $this->quba->get_response_summary($this->slot)); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 10); + + // Verify. + $this->check_current_state(question_state::$mangrright); + $this->check_current_mark(10); + $this->check_current_output( + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + + // Now change the max mark for the question and regrade. + $this->quba->regrade_question($this->slot, true, 1); + + // Verify. + $this->check_current_state(question_state::$mangrright); + $this->check_current_mark(1); + } + + public function test_manual_graded_truefalse() { + + // Create a true-false question with correct answer true. + $tf = test_question_maker::make_a_truefalse_question(); + $this->start_attempt_at_question($tf, 'manualgraded', 2); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_question_text_expectation($tf), + $this->get_does_not_contain_feedback_expectation()); + + // Process a true answer and check the expected result. + $this->process_submission(array('answer' => 1)); + + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_tf_true_radio_expectation(true, true), + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_feedback_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$needsgrading); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_specific_feedback_expectation()); + + // Process a manual comment. + $this->manual_grade('Not good enough!', 1); + + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(1); + $this->check_current_output( + $this->get_does_not_contain_correctness_expectation(), + $this->get_does_not_contain_specific_feedback_expectation(), + new PatternExpectation('/' . preg_quote('Not good enough!') . '/')); + } + + public function test_manual_graded_ignore_repeat_sumbission() { + // Create an essay question. + $essay = test_question_maker::make_an_essay_question(); + $this->start_attempt_at_question($essay, 'deferredfeedback', 10); + + // Check the right model is being used. + $this->assertEqual('manualgraded', $this->quba-> + get_question_attempt($this->slot)->get_behaviour_name()); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + + // Simulate some data submitted by the student. + $this->process_submission(array('answer' => 'This is my wonderful essay!')); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$needsgrading); + $this->check_current_mark(null); + $this->assertEqual('This is my wonderful essay!', + $this->quba->get_response_summary($this->slot)); + + // Process a blank manual comment. Ensure it does not change the state. + $numsteps = $this->get_step_count(); + $this->manual_grade('', ''); + $this->check_step_count($numsteps); + $this->check_current_state(question_state::$needsgrading); + $this->check_current_mark(null); + + // Process a comment, but with the mark blank. Should be recorded, but + // not change the mark. + $this->manual_grade('I am not sure what grade to award.', ''); + $this->check_step_count($numsteps + 1); + $this->check_current_state(question_state::$needsgrading); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/' . preg_quote('I am not sure what grade to award.') . '/')); + + // Now grade it. + $this->manual_grade('Pretty good!', '9.00000'); + $this->check_step_count($numsteps + 2); + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(9); + $this->check_current_output( + new PatternExpectation('/' . preg_quote('Pretty good!') . '/')); + + // Process the same data again, and make sure it does not add a step. + $this->manual_grade('Pretty good!', '9.00000'); + $this->check_step_count($numsteps + 2); + $this->check_current_state(question_state::$mangrpartial); + $this->check_current_mark(9); + + // Now set the mark back to blank. + $this->manual_grade('Actually, I am not sure any more.', ''); + $this->check_step_count($numsteps + 3); + $this->check_current_state(question_state::$needsgrading); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/' . preg_quote('Actually, I am not sure any more.') . '/')); + + $qa = $this->quba->get_question_attempt($this->slot); + $this->assertEqual('Commented: Actually, I am not sure any more.', + $qa->summarise_action($qa->get_last_step())); + } + + public function test_manual_graded_essay_can_grade_0() { + + // Create an essay question. + $essay = test_question_maker::make_an_essay_question(); + $this->start_attempt_at_question($essay, 'deferredfeedback', 10); + + // Check the right model is being used. + $this->assertEqual('manualgraded', $this->quba-> + get_question_attempt($this->slot)->get_behaviour_name()); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output($this->get_contains_question_text_expectation($essay), + $this->get_does_not_contain_feedback_expectation()); + + // Simulate some data submitted by the student. + $this->process_submission(array('answer' => 'This is my wonderful essay!')); + + // Verify. + $this->check_current_state(question_state::$complete); + $this->check_current_mark(null); + $this->check_current_output( + new ContainsTagWithAttribute('textarea', 'name', + $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')), + $this->get_does_not_contain_feedback_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$needsgrading); + $this->check_current_mark(null); + $this->assertEqual('This is my wonderful essay!', + $this->quba->get_response_summary($this->slot)); + + // Process a blank comment and a grade of 0. + $this->manual_grade('', 0); + + // Verify. + $this->check_current_state(question_state::$mangrwrong); + $this->check_current_mark(0); + } +} diff --git a/question/behaviour/missing/behaviour.php b/question/behaviour/missing/behaviour.php new file mode 100644 index 00000000000..edcc73ee954 --- /dev/null +++ b/question/behaviour/missing/behaviour.php @@ -0,0 +1,65 @@ +. + + +/** + * Fake question behaviour that is used when the actual qim was not + * available. + * + * @package qbehaviour_missing + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Fake question behaviour that is used when the actual behaviour + * is not available. + * + * Imagine, for example, that a quiz attempt has been restored from another + * Moodle site with more behaviours installed, or an behaviour + * that used to be available in this site has been uninstalled. Obviously all we + * can do is have some code to prevent fatal errors. + * + * The approach we take is: The rendering code is still implemented, as far as + * possible. A warning is shown that behaviour specific bits may be missing. + * Any attempt to process anything causes an exception to be thrown. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_missing extends question_behaviour { + public function required_question_definition_type() { + return 'question_definition'; + } + + public function summarise_action(question_attempt_step $step) { + return ''; + } + + public function init_first_step(question_attempt_step $step) { + throw new Exception('The behaviour used for this question is not available. No processing is possible.'); + } + + public function process_action(question_attempt_pending_step $pendingstep) { + throw new Exception('The behaviour used for this question is not available. No processing is possible.'); + } + + public function get_min_fraction() { + throw new Exception('The behaviour used for this question is not available. No processing is possible.'); + } +} diff --git a/question/behaviour/missing/lang/en_utf8/qbehaviour_missing.php b/question/behaviour/missing/lang/en_utf8/qbehaviour_missing.php new file mode 100644 index 00000000000..c684c0cd94e --- /dev/null +++ b/question/behaviour/missing/lang/en_utf8/qbehaviour_missing.php @@ -0,0 +1,3 @@ +. + + +/** + * Renderer for outputting parts of a question when the actual behaviour + * used is not available. + * + * @package qbehaviour_missing + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +class qbehaviour_missing_renderer extends qbehaviour_renderer { + public function controls(question_attempt $qa, question_display_options $options) { + return html_writer::tag('div', + get_string('questionusedunknownmodel', 'qbehaviour_missing'), + array('class' => 'warning')); + } +} \ No newline at end of file diff --git a/question/behaviour/missing/simpletest/testmissingbehaviour.php b/question/behaviour/missing/simpletest/testmissingbehaviour.php new file mode 100644 index 00000000000..a4bd3df8120 --- /dev/null +++ b/question/behaviour/missing/simpletest/testmissingbehaviour.php @@ -0,0 +1,96 @@ +. + + +/** + * This file contains tests for the 'missing' behaviour. + * + * @package qbehaviour_missing + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); +require_once(dirname(__FILE__) . '/../behaviour.php'); + +class qbehaviour_missing_test extends UnitTestCase { + public function test_missing_cannot_start() { + $qa = new question_attempt(test_question_maker::make_a_truefalse_question(), 0); + $behaviour = new qbehaviour_missing($qa, 'deferredfeedback'); + $this->expectException(); + $behaviour->init_first_step(new question_attempt_step(array())); + } + + public function test_missing_cannot_process() { + $qa = new question_attempt(test_question_maker::make_a_truefalse_question(), 0); + $behaviour = new qbehaviour_missing($qa, 'deferredfeedback'); + $this->expectException(); + $behaviour->process_action(new question_attempt_pending_step(array())); + } + + public function test_missing_cannot_get_min_grade() { + $qa = new question_attempt(test_question_maker::make_a_truefalse_question(), 0); + $behaviour = new qbehaviour_missing($qa, 'deferredfeedback'); + $this->expectException(); + $behaviour->get_min_fraction(); + } + + public function test_render_missing() { + $records = testing_db_record_builder::build_db_records(array( + array('id', 'questionattemptid', 'questionusageid', 'slot', + 'behaviour', 'questionid', 'maxmark', 'minfraction', 'flagged', + 'questionsummary', 'rightanswer', 'responsesummary', 'timemodified', + 'attemptstepid', 'sequencenumber', 'state', 'fraction', + 'timecreated', 'userid', 'name', 'value'), + array(1, 1, 1, 1, 'strangeunknown', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, '_order', '1,2,3'), + array(2, 1, 1, 1, 'strangeunknown', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', 0.50, 1256233705, 1, '-submit', '1'), + array(3, 1, 1, 1, 'strangeunknown', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', 0.50, 1256233705, 1, 'choice0', '1'), + )); + + $question = test_question_maker::make_a_truefalse_question(); + $question->id = -1; + + question_bank::start_unit_test(); + question_bank::load_test_question_data($question); + $qa = question_attempt::load_from_records($records, 1, + new question_usage_null_observer(), 'deferredfeedback'); + question_bank::end_unit_test(); + + $this->assertEqual(2, $qa->get_num_steps()); + + $step = $qa->get_step(0); + $this->assertEqual(question_state::$todo, $step->get_state()); + $this->assertNull($step->get_fraction()); + $this->assertEqual(1256233700, $step->get_timecreated()); + $this->assertEqual(1, $step->get_user_id()); + $this->assertEqual(array('_order' => '1,2,3'), $step->get_all_data()); + + $step = $qa->get_step(1); + $this->assertEqual(question_state::$complete, $step->get_state()); + $this->assertEqual(0.5, $step->get_fraction()); + $this->assertEqual(1256233705, $step->get_timecreated()); + $this->assertEqual(1, $step->get_user_id()); + $this->assertEqual(array('-submit' => '1', 'choice0' => '1'), $step->get_all_data()); + + $output = $qa->render(new question_display_options(), '1'); + $this->assertPattern('/' . preg_quote($qa->get_question()->questiontext) . '/', $output); + $this->assertPattern('/' . preg_quote(get_string('questionusedunknownmodel', 'qbehaviour_missing')) . '/', $output); + $this->assert(new ContainsTagWithAttribute('div', 'class', 'warning'), $output); + } +} diff --git a/question/behaviour/opaque/behaviour.php b/question/behaviour/opaque/behaviour.php new file mode 100644 index 00000000000..c43f68ba0ee --- /dev/null +++ b/question/behaviour/opaque/behaviour.php @@ -0,0 +1,197 @@ +. + + +/** + * This behaviour that is used when the actual qim was not + * available. + * + * @package qbehaviour_opaque + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * This behaviour is specifically for use with the Opaque question type. + * + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbehaviour_opaque extends question_behaviour { + /** @var string */ + protected $preferredbehaviour; + /** @var string */ + protected $questionsummary; + + public function __construct(question_attempt $qa, $preferredbehaviour) { + parent::__construct($qa, $preferredbehaviour); + $this->preferredbehaviour = $preferredbehaviour; + } + + public function required_question_definition_type() { + return 'qtype_opaque_question'; + } + + public function get_state_string($showcorrectness) { + $state = $this->qa->get_state(); + $omstate = $this->qa->get_last_behaviour_var('_statestring'); + if ($state->is_finished()) { + return $state->default_string($showcorrectness); + } else if ($omstate) { + return $omstate; + } else { + return get_string('notcomplete', 'qbehaviour_opaque'); + } + } + + public function init_first_step(question_attempt_step $step) { + global $USER; + + if ($step->has_behaviour_var('_randomseed')) { + // Reinitialising, nothing to do. + return; + } + + // Set up the random seed to be the current time in milliseconds. + list($micros, $sec) = explode(" ", microtime()); + $step->set_behaviour_var('_randomseed', $sec . floor($micros * 1000)); + $step->set_behaviour_var('_userid', $USER->id); + $step->set_behaviour_var('_language', current_language()); + $step->set_behaviour_var('_preferredbehaviour', $this->preferredbehaviour); + $opaquestate = update_opaque_state($this->qa, $step); + $step->set_behaviour_var('_statestring', $opaquestate->progressinfo); + + // Remember the question summary. + $this->questionsummary = html_to_text($opaquestate->xhtml, 0, false); + } + + public function get_question_summary() { + return $this->questionsummary; + } + + protected function is_same_response(question_attempt_step $pendingstep) { + $newdata = $pendingstep->get_submitted_data(); + + foreach ($newdata as $key => $ignored) { + // If an omact_ button has been clicked, never treat this as a duplicate submission. + if (strpos($key, 'omact_') === 0) { + return false; + } + } + + $olddata = $this->qa->get_last_step()->get_submitted_data(); + return question_utils::arrays_have_same_keys_and_values($newdata, $olddata); + } + + public function summarise_action(question_attempt_step $step) { + if ($step->has_behaviour_var('finish')) { + return $this->summarise_finish($step); + } else if ($step->has_behaviour_var('comment')) { + return $this->summarise_manual_comment($step); + } else { + $data = $step->get_qt_data(); + $formatteddata = array(); + foreach ($data as $name => $value) { + if (substr($name, 0, 1) == '_') { + continue; + } + $formatteddata[] = $name . ' => ' . s($value); + } + if ($formatteddata) { + return get_string('submitted', 'question', implode(', ', $formatteddata)); + } else { + return $this->summarise_start($step); + } + } + } + + public function process_action(question_attempt_pending_step $pendingstep) { + if ($pendingstep->has_behaviour_var('finish')) { + return $this->process_finish($pendingstep); + } + if ($pendingstep->has_behaviour_var('comment')) { + return $this->process_comment($pendingstep); + } else if ($this->is_same_response($pendingstep) || + $this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } else { + return $this->process_remote_action($pendingstep); + } + } + + public function process_finish(question_attempt_pending_step $pendingstep) { + if ($this->qa->get_state()->is_finished()) { + return question_attempt::DISCARD; + } + + // They tried to finish the usage without having finished this question. + // That is, they gave up. + $pendingstep->set_state(question_state::$gaveup); + return question_attempt::KEEP; + } + + public function process_remote_action(question_attempt_pending_step $pendingstep) { + $opaquestate = update_opaque_state($this->qa, $pendingstep); + + if (is_string($opaquestate)) { + notify($opaquestate); + return question_attempt::DISCARD; // TODO + } + + if ($opaquestate->resultssequencenumber != $this->qa->get_num_steps()) { + $pendingstep->set_state(question_state::$todo); + $pendingstep->set_behaviour_var('_statestring', $opaquestate->progressinfo); + + } else { + // Look for a score on the default axis. + $pendingstep->set_fraction(0); + foreach ($opaquestate->results->scores as $score) { + if ($score->axis == '') { + $pendingstep->set_fraction($score->marks / $this->question->defaultmark); + } + } + + if ($opaquestate->results->attempts > 0) { + $pendingstep->set_state(question_state::$gradedright); + } else { + $pendingstep->set_state( + question_state::graded_state_for_fraction($pendingstep->get_fraction())); + } + + if (!empty($opaquestate->results->questionLine)) { + $this->qa->set_question_summary( + $this->cleanup_results($opaquestate->results->questionLine)); + } + if (!empty($opaquestate->results->answerLine)) { + $pendingstep->set_new_response_summary( + $this->cleanup_results($opaquestate->results->answerLine)); + } + if (!empty($opaquestate->results->actionSummary)) { + $pendingstep->set_behaviour_var('_actionsummary', + $this->cleanup_results($opaquestate->results->actionSummary)); + } + } + + return question_attempt::KEEP; + } + + protected function cleanup_results($line) { + return preg_replace('/\\s+/', ' ', $line); + } +} diff --git a/question/behaviour/opaque/lang/en_utf8/qbehaviour_opaque.php b/question/behaviour/opaque/lang/en_utf8/qbehaviour_opaque.php new file mode 100644 index 00000000000..aa11ab54c57 --- /dev/null +++ b/question/behaviour/opaque/lang/en_utf8/qbehaviour_opaque.php @@ -0,0 +1,4 @@ +. + + +/** + * Renderer for outputting parts of a question when the actual behaviour + * used is not available. + * + * @package qbehaviour_opaque + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +class qbehaviour_opaque_renderer extends qbehaviour_renderer { + public function controls(question_attempt $qa, question_display_options $options) { + if ($qa->get_state()->is_gave_up()) { + return html_writer::tag('div', get_string('notcompletedmessage', 'qtype_opaque'), + array('class' => 'question_aborted')); + } + + $opaquestate =& update_opaque_state($qa); + if (is_string($opaquestate)) { + return notify($opaquestate, '', '', true); + // TODO + } + + return html_writer::tag('div', $opaquestate->xhtml, + array('class' => opaque_browser_type())); + } + + public function head_code(question_attempt $qa) { + $output = ''; + $opaquestate =& update_opaque_state($qa); + + $question = $qa->get_question(); + $resourcecache = new opaque_resource_cache($question->engineid, + $question->remoteid, $question->remoteversion); + + if (!empty($opaquestate->cssfilename) && $resourcecache->file_in_cache($opaquestate->cssfilename)) { + $output .= ''; + } + + if(!empty($opaquestate->headXHTML)) { + $output .= $opaquestate->headXHTML; + } + + return $output; + } +} \ No newline at end of file diff --git a/question/behaviour/opaque/simpletest/testopaquebehaviour.php b/question/behaviour/opaque/simpletest/testopaquebehaviour.php new file mode 100644 index 00000000000..6040b55f6c4 --- /dev/null +++ b/question/behaviour/opaque/simpletest/testopaquebehaviour.php @@ -0,0 +1,227 @@ +. + + +/** + * This file contains tests for the Opaque behaviour. + * + * @package qbehaviour_opaque + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../../../engine/lib.php'); +require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php'); +require_once(dirname(__FILE__) . '/../behaviour.php'); + +class qbehaviour_opaque_test extends qbehaviour_walkthrough_test_base { + /** + * Makes an Opaque question that refers to one of the sample questions + * supplied by OpenMark. + * @return unknown_type + */ + protected function make_standard_om_question() { + $engineid = get_field('question_opaque_engines', 'MIN(id)', '', ''); + if (empty($engineid)) { + throw new Exception('Cannot test Opaque. No question engines configured.'); + } + + question_bank::load_question_definition_classes('opaque'); + $q = new qtype_opaque_question(); + test_question_maker::initialise_a_question($q); + + $q->name = 'samples.mu120.module5.question01'; + $q->qtype = question_bank::get_qtype('opaque'); + $q->defaultmark = 3; + + $q->engineid = $engineid; + $q->remoteid = 'samples.mu120.module5.question01'; + $q->remoteversion = '1.0'; + + return $q; + } + + public function test_wrong_three_times() { + $q = $this->make_standard_om_question(); + $this->start_attempt_at_question($q, 'interactive'); + $qa = $this->quba->get_question_attempt($this->slot); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/Below is a plan of a proposed garden/'), + new PatternExpectation('/You have 3 attempts/'), + $this->get_contains_button_expectation($qa->get_qt_field_name('omact_gen_14'), 'Check')); + $this->assertPattern('/^\s*Below is a plan of a proposed garden./', + $qa->get_question_summary()); + $this->assertNull($qa->get_right_answer_summary()); + + // Submit a wrong answer. + $this->process_submission(array('omval_response1' => 1, 'omval_response2' => 666, + 'omact_gen_14' => 'Check')); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/Below is a plan of a proposed garden/'), + new PatternExpectation('/incorrect/'), + new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_opaque')) . '/'), + $this->get_contains_button_expectation($qa->get_qt_field_name('omact_ok'), 'Try again')); + + // Try again. + $this->process_submission(array('omact_ok' => 'Try again')); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/You have 2 attempts/')); + + // Submit a wrong answer again. + $this->process_submission(array('omval_response1' => 1, 'omval_response2' => 666, + 'omact_gen_14' => 'Check')); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/Below is a plan of a proposed garden/'), + new PatternExpectation('/still incorrect/'), + new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_opaque')) . '/')); + + // Try again. + $this->process_submission(array('omact_ok' => 'Try again')); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/This is your last attempt/')); + + // Submit a wrong answer third time. + $this->process_submission(array('omval_response1' => 1, 'omval_response2' => 666, + 'omact_gen_14' => 'Check')); + + // Verify. + $this->check_current_state(question_state::$gradedwrong); + $this->check_current_mark(0); + $this->check_current_output( + new PatternExpectation('/Please see MU120 Preparatory Resource Book B section 5.1/'), + new PatternExpectation('/still incorrect/')); + $this->assertTrue(preg_match('/What is \(X\*W\) (\d+\.\d+)\*(\d+), \(X\*L\)(\d+\.\d+)\*(\d+)\?/', + $qa->get_question_summary(), $matches)); + $this->assertNull($qa->get_right_answer_summary()); + $this->assertPattern('/' . $matches[1]*$matches[2] . '.*, ' . $matches[3]*$matches[4] . '/', + $qa->get_response_summary()); + } + + public function test_right_first_time() { + $q = $this->make_standard_om_question(); + $this->start_attempt_at_question($q, 'interactive'); + $qa = $this->quba->get_question_attempt($this->slot); + + // Work out right answer (yuck!) + $html = $this->quba->render_question($this->slot, $this->displayoptions); + preg_match('/(0\.5|2\.0|3\.0) metres/', $html, $matches); + $scale = $matches[1]; + preg_match('/Patio|Summer House|Flowerbed|Vegetable Plot|Pond/', $html, $matches); + $feature = $matches[0]; + $sizes = array( + 'Patio' => array(4, 7), + 'Summer House' => array(3, 5), + 'Flowerbed' => array(2, 7), + 'Vegetable Plot' => array(3, 10), + 'Pond' => array(2, 3), + ); + $size = $sizes[$feature]; + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/Below is a plan of a proposed garden/'), + new PatternExpectation('/You have 3 attempts/'), + $this->get_contains_button_expectation($qa->get_qt_field_name('omact_gen_14'), 'Check')); + + // Submit the right answer. + $this->process_submission(array('omval_response1' => $size[0] * $scale, + 'omval_response2' => $size[1] * $scale, 'omact_gen_14' => 'Check')); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(3); + $this->check_current_output( + new PatternExpectation('/Below is a plan of a proposed garden/'), + new PatternExpectation('/correct/')); + } + + public function test_different_max() { + $q = $this->make_standard_om_question(); + $this->start_attempt_at_question($q, 'interactive', 6.0); + $qa = $this->quba->get_question_attempt($this->slot); + + // Work out right answer (yuck!) + $html = $this->quba->render_question($this->slot, $this->displayoptions); + preg_match('/(0\.5|2\.0|3\.0) metres/', $html, $matches); + $scale = $matches[1]; + preg_match('/Patio|Summer House|Flowerbed|Vegetable Plot|Pond/', $html, $matches); + $feature = $matches[0]; + $sizes = array( + 'Patio' => array(4, 7), + 'Summer House' => array(3, 5), + 'Flowerbed' => array(2, 7), + 'Vegetable Plot' => array(3, 10), + 'Pond' => array(2, 3), + ); + $size = $sizes[$feature]; + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/Below is a plan of a proposed garden/'), + new PatternExpectation('/You have 3 attempts/'), + $this->get_contains_button_expectation($qa->get_qt_field_name('omact_gen_14'), 'Check')); + + // Submit the right answer. + $this->process_submission(array('omval_response1' => $size[0] * $scale, + 'omval_response2' => $size[1] * $scale, 'omact_gen_14' => 'Check')); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(6); + $this->check_current_output( + new PatternExpectation('/Below is a plan of a proposed garden/'), + new PatternExpectation('/correct/')); + } + + public function test_gave_up() { + $q = $this->make_standard_om_question(); + $this->start_attempt_at_question($q, 'interactive'); + + $this->quba->finish_all_questions(); + + $this->check_current_state(question_state::$gaveup); + $this->check_current_mark(null); + $this->check_current_output( + new PatternExpectation('/' . + preg_quote(get_string('notcompletedmessage', 'qtype_opaque')) . '/')); + } +} diff --git a/question/behaviour/rendererbase.php b/question/behaviour/rendererbase.php new file mode 100644 index 00000000000..de2da4b7281 --- /dev/null +++ b/question/behaviour/rendererbase.php @@ -0,0 +1,200 @@ +. + + +/** + * Defines the renderer base class for question behaviours. + * + * @package moodlecore + * @subpackage questionbehaviours + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Renderer base class for question behaviours. + * + * The methods in this class are mostly called from {@link core_question_renderer} + * which coordinates the overall output of questions. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class qbehaviour_renderer extends renderer_base { + /** + * Generate some HTML (which may be blank) that appears in the question + * formulation area, afer the question type generated output. + * + * For example. + * immediatefeedback and interactive mode use this to show the Submit button, + * and CBM use this to display the certainty choices. + * + * @param question_attempt $qa a question attempt. + * @param question_display_options $options controls what should and should not be displayed. + * @return string HTML fragment. + */ + public function controls(question_attempt $qa, question_display_options $options) { + return ''; + } + + /** + * Generate some HTML (which may be blank) that appears in the outcome area, + * after the question-type generated output. + * + * For example, the CBM models use this to display an explanation of the score + * adjustment that was made based on the certainty selected. + * + * @param question_attempt $qa a question attempt. + * @param question_display_options $options controls what should and should not be displayed. + * @return string HTML fragment. + */ + public function feedback(question_attempt $qa, question_display_options $options) { + return ''; + } + + public function manual_comment_fields(question_attempt $qa, question_display_options $options) { + + $commentfield = $qa->get_behaviour_field_name('comment'); + + $comment = print_textarea(can_use_html_editor(), 10, 80, null, null, $commentfield, $qa->get_manual_comment(), 0, true); + $comment = html_writer::tag('div', html_writer::tag('div', + html_writer::tag('label', get_string('comment', 'question'), array('for' => $commentfield)), + array('class' => 'fitemtitle')) . + html_writer::tag('div', $comment, array('class' => 'felement fhtmleditor')), + array('class' => 'fitem')); + + $mark = ''; + if ($qa->get_max_mark()) { + $currentmark = $qa->get_current_manual_mark(); + $maxmark = $qa->get_max_mark(); + + $fieldsize = strlen($qa->format_max_mark($options->markdp)) - 1; + $markfield = $qa->get_behaviour_field_name('mark'); + + $attributes = array( + 'type' => 'text', + 'size' => $fieldsize, + 'name' => $markfield, + ); + if (!is_null($currentmark)) { + $attributes['value'] = $qa->format_fraction_as_mark($currentmark / $maxmark, $options->markdp); + } + $a = new stdClass; + $a->max = $qa->format_max_mark($options->markdp); + $a->mark = html_writer::empty_tag('input', $attributes); + + $markrange = html_writer::empty_tag('input', array( + 'type' => 'hidden', + 'name' => $qa->get_behaviour_field_name('maxmark'), + 'value' => $maxmark, + )) . html_writer::empty_tag('input', array( + 'type' => 'hidden', + 'name' => $qa->get_control_field_name('minfraction'), + 'value' => $qa->get_min_fraction(), + )); + + $errorclass = ''; + $error = ''; + if ($currentmark > $maxmark || $currentmark < $maxmark * $qa->get_min_fraction()) { + $errorclass = ' error'; + $error = html_writer::tag('span', get_string('manualgradeoutofrange', 'question'), + array('class' => 'error')) . html_writer::empty_tag('br'); + } + + $mark = html_writer::tag('div', html_writer::tag('div', + html_writer::tag('label', get_string('mark', 'question'), array('for' => $markfield)), + array('class' => 'fitemtitle')) . + html_writer::tag('div', $error . get_string('xoutofmax', 'question', $a) . + $markrange, array('class' => 'felement ftext' . $errorclass) + ), array('class' => 'fitem')); + + } + + return html_writer::tag('fieldset', html_writer::tag('div', $comment . $mark, + array('class' => 'fcontainer clearfix')), array('class' => 'hidden')); + } + + public function manual_comment_view(question_attempt $qa, question_display_options $options) { + $output = ''; + if ($qa->has_manual_comment()) { + $output .= get_string('commentx', 'question', $qa->get_behaviour()->format_comment()); + } + if ($options->manualcommentlink) { + $strcomment = get_string('commentormark', 'question'); + $link = link_to_popup_window($options->manualcommentlink . + '&slot=' . $qa->get_slot(), + 'commentquestion', $strcomment, 600, 800, $strcomment, 'none', true); + $output .= html_writer::tag('div', $link, array('class' => 'commentlink')); + } + return $output; + } + + /** + * Display the manual comment, and a link to edit it, if appropriate. + * + * @param question_attempt $qa a question attempt. + * @param question_display_options $options controls what should and should not be displayed. + * @return string HTML fragment. + */ + public function manual_comment(question_attempt $qa, question_display_options $options) { + if ($options->manualcomment == question_display_options::EDITABLE) { + return $this->manual_comment_fields($qa, $options); + + } else if ($options->manualcomment == question_display_options::VISIBLE) { + return $this->manual_comment_view($qa, $options); + + } else { + return ''; + } + } + + /** + * Several behaviours need a submit button, so put the common code here. + * The button is disabled if the question is displayed read-only. + * @param question_display_options $options controls what should and should not be displayed. + * @return string HTML fragment. + */ + protected function submit_button(question_attempt $qa, question_display_options $options) { + $attributes = array( + 'type' => 'submit', + 'id' => $qa->get_behaviour_field_name('submit'), + 'name' => $qa->get_behaviour_field_name('submit'), + 'value' => get_string('check', 'question'), + 'class' => 'submit btn', + ); + if ($options->readonly) { + $attributes['disabled'] = 'disabled'; + } + $output = html_writer::empty_tag('input', $attributes); + if (!$options->readonly) { + $output .= print_js_call('question_init_submit_button', + array($attributes['id'], $qa->get_slot()), true); + } + return $output; + } + + /** + * Return any HTML that needs to be included in the page's
when + * questions using this model are used. + * @param $qa the question attempt that will be displayed on the page. + * @return string HTML fragment. + */ + public function head_code(question_attempt $qa) { + return ''; + } +} diff --git a/question/editlib.php b/question/editlib.php index b07ad7551c0..d03a78c8a9b 100644 --- a/question/editlib.php +++ b/question/editlib.php @@ -1,37 +1,34 @@ . + /** * Functions used to show question editing interface * - * @license http://www.gnu.org/copyleft/gpl.html GNU Public License - * @package questionbank - *//** */ + * @package moodlecore + * @subpackage questionbank + * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + require_once($CFG->libdir.'/questionlib.php'); + define('DEFAULT_QUESTIONS_PER_PAGE', 20); function get_module_from_cmid($cmid) { @@ -1428,10 +1425,10 @@ class question_bank_view { foreach ($questionlist as $questionid) { $questionid = (int)$questionid; question_require_capability_on($questionid, 'edit'); - if ($DB->record_exists('quiz_question_instances', array('question' => $questionid))) { + if (questions_in_use(array($questionid))) { $DB->set_field('question', 'hidden', 1, array('id' => $questionid)); } else { - delete_question($questionid); + question_delete_question($questionid); } } } @@ -1464,7 +1461,7 @@ class question_bank_view { $key = $matches[1]; $questionlist .= $key.','; question_require_capability_on($key, 'edit'); - if ($DB->record_exists('quiz_question_instances', array('question' => $key))) { + if (questions_in_use(array($key))) { $questionnames .= '* '; $inuse = true; } diff --git a/question/engine/bank.php b/question/engine/bank.php new file mode 100644 index 00000000000..3bc394a2e45 --- /dev/null +++ b/question/engine/bank.php @@ -0,0 +1,221 @@ +. + + +/** + * More object oriented wrappers around parts of the Moodle question bank. + * + * In due course, I expect that the question bank will be converted to a + * fully object oriented structure, at which point this file can be a + * starting point. + * + * @package moodlecore + * @subpackage questionbank + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * This static class provides access to the other question bank. + * + * It provides functions for managing question types and question definitions. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_bank { + /** @var array question type name => question_type subclass. */ + private static $questiontypes = array(); + + /** @var array question type name => 1. Records which question definitions have been loaded. */ + private static $loadedqdefs = array(); + + protected static $questionfinder = null; + + /** @var boolean nasty hack to allow unit tests to call {@link load_question()}. */ + private static $testmode = false; + private static $testdata = array(); + + /** + * Get the question type class for a particular question type. + * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'. + * @param boolean $mustexist if false, the missing question type is returned when + * the requested question type is not installed. + * @return question_type the corresponding question type class. + */ + public static function get_qtype($qtypename, $mustexist = true) { + global $CFG; + if (isset(self::$questiontypes[$qtypename])) { + return self::$questiontypes[$qtypename]; + } + $file = $CFG->dirroot . '/question/type/' . $qtypename . '/questiontype.php'; + if (!is_readable($file)) { + if ($mustexist || $qtypename == 'missingtype') { + throw new Exception('Unknown question type ' . $qtypename); + } else { + return self::get_qtype('missingtype'); + } + } + include_once($file); + $class = 'qtype_' . $qtypename; + self::$questiontypes[$qtypename] = new $class(); + return self::$questiontypes[$qtypename]; + } + + /** + * @param $qtypename the internal name of a question type, for example multichoice. + * @return string the human_readable name of this question type, from the language pack. + */ + public static function get_qtype_name($qtypename) { + return self::get_qtype($qtypename)->menu_name(); + } + + /** + * @return array all the installed question types. + */ + public static function get_all_qtypes() { + $qtypes = array(); + $plugins = get_list_of_plugins('question/type', 'datasetdependent'); + foreach ($plugins as $plugin) { + $qtypes[$plugin] = self::get_qtype($plugin); + } + return $qtypes; + } + + /** + * Load the question definition class(es) belonging to a question type. That is, + * include_once('/question/type/' . $qtypename . '/question.php'), with a bit + * of checking. + * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'. + */ + public static function load_question_definition_classes($qtypename) { + global $CFG; + if (isset(self::$loadedqdefs[$qtypename])) { + return; + } + $file = $CFG->dirroot . '/question/type/' . $qtypename . '/question.php'; + if (!is_readable($file)) { + throw new Exception('Unknown question type (no definition) ' . $qtypename); + } + include_once($file); + self::$loadedqdefs[$qtypename] = 1; + } + + /** + * Load a question definition from the database. The object returned + * will actually be of an appropriate {@link question_definition} subclass. + * @param integer $questionid the id of the question to load. + * @return question_definition loaded from the database. + */ + public static function load_question($questionid) { + if (self::$testmode) { + // Evil, test code in production, but now way round it. + return self::return_test_question_data($questionid); + } + + $questiondata = get_record('question', 'id', $questionid); + if (empty($questiondata)) { + throw new Exception('Unknown question id ' . $questionid); + } + get_question_options($questiondata); + return self::make_question($questiondata); + } + + /** + * Convert the question information loaded with {@link get_question_options()} + * to a question_definintion object. + * @param object $questiondata raw data loaded from the database. + * @return question_definition loaded from the database. + */ + public static function make_question($questiondata) { + return self::get_qtype($questiondata->qtype, false)->make_question($questiondata, false); + } + + /** + * @return question_finder a question finder. + */ + public static function get_finder() { + if (is_null(self::$questionfinder)) { + self::$questionfinder = new question_finder(); + } + return self::$questionfinder; + } + + /** + * Only to be called from unit tests. Allows {@link load_test_data()} to be used. + */ + public static function start_unit_test() { + self::$testmode = true; + } + + /** + * Only to be called from unit tests. Allows {@link load_test_data()} to be used. + */ + public static function end_unit_test() { + self::$testmode = false; + self::$testdata = array(); + } + + private static function return_test_question_data($questionid) { + if (!isset(self::$testdata[$questionid])) { + throw new Exception('question_bank::return_test_data(' . $questionid . + ') called, but no matching question has been loaded by load_test_data.'); + } + return self::$testdata[$questionid]; + } + + /** + * To be used for unit testing only. Will throw an exception if + * {@link start_unit_test()} has not been called first. + * @param object $questiondata a question data object to put in the test data store. + */ + public static function load_test_question_data(question_definition $question) { + if (!self::$testmode) { + throw new Exception('question_bank::load_test_data called when not in test mode.'); + } + self::$testdata[$question->id] = $question; + } +} + +class question_finder { + /** + * Get the ids of all the questions in a list of categoryies. + * @param integer|string|array $categoryids either a categoryid, or a comma-separated list + * category ids, or an array of them. + * @param string $extraconditions extra conditions to AND with the rest of the where clause. + * @return array questionid => questionid. + */ + public function get_questions_from_categories($categoryids, $extraconditions) { + if (is_array($categoryids)) { + $categoryids = implode(',', $categoryids); + } + + if ($extraconditions) { + $extraconditions = ' AND (' . $extraconditions . ')'; + } + $questionids = get_records_select_menu('question', + "category IN ($categoryids) + AND parent = 0 + AND hidden = 0 + $extraconditions", '', 'id,id AS id2'); + if (!$questionids) { + $questionids = array(); + } + return $questionids; + } +} diff --git a/question/engine/datalib.php b/question/engine/datalib.php new file mode 100644 index 00000000000..cd1c9974a34 --- /dev/null +++ b/question/engine/datalib.php @@ -0,0 +1,1101 @@ +. + + +/** + * Code for loading and saving quiz attempts to and from the database. + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * This class controls the loading and saving of question engine data to and from + * the database. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_engine_data_mapper { + /** + * Store an entire {@link question_usage_by_activity} in the database, + * including all the question_attempts that comprise it. + * @param question_usage_by_activity $quba the usage to store. + */ + public function insert_questions_usage_by_activity(question_usage_by_activity $quba) { + $record = new stdClass; + $record->contextid = $quba->get_owning_context()->id; + $record->component = addslashes($quba->get_owning_component()); + $record->preferredbehaviour = addslashes($quba->get_preferred_behaviour()); + + $newid = insert_record('question_usages', $record); + if (!$newid) { + throw new Exception('Failed to save questions_usage_by_activity.'); + } + $quba->set_id_from_database($newid); + + foreach ($quba->get_attempt_iterator() as $qa) { + $this->insert_question_attempt($qa); + } + } + + /** + * Store an entire {@link question_attempt} in the database, + * including all the question_attempt_steps that comprise it. + * @param question_attempt $qa the question attempt to store. + */ + public function insert_question_attempt(question_attempt $qa) { + $record = new stdClass; + $record->questionusageid = $qa->get_usage_id(); + $record->slot = $qa->get_slot(); + $record->behaviour = addslashes($qa->get_behaviour_name()); + $record->questionid = $qa->get_question()->id; + $record->maxmark = $qa->get_max_mark(); + $record->minfraction = $qa->get_min_fraction(); + $record->flagged = $qa->is_flagged(); + $record->questionsummary = addslashes($qa->get_question_summary()); + $record->rightanswer = addslashes($qa->get_right_answer_summary()); + $record->responsesummary = addslashes($qa->get_response_summary()); + $record->timemodified = time(); + $record->id = insert_record('question_attempts', $record); + if (!$record->id) { + throw new Exception('Failed to save question_attempt ' . $qa->get_slot()); + } + + foreach ($qa->get_step_iterator() as $seq => $step) { + $this->insert_question_attempt_step($step, $record->id, $seq); + } + } + + /** + * Store a {@link question_attempt_step} in the database. + * @param question_attempt_step $qa the step to store. + */ + public function insert_question_attempt_step(question_attempt_step $step, + $questionattemptid, $seq) { + $record = new stdClass; + $record->questionattemptid = $questionattemptid; + $record->sequencenumber = $seq; + $record->state = addslashes('' . $step->get_state()); + $record->fraction = $step->get_fraction(); + $record->timecreated = $step->get_timecreated(); + $record->userid = $step->get_user_id(); + + $record->id = insert_record('question_attempt_steps', $record); + if (!$record->id) { + throw new Exception('Failed to save question_attempt_step' . $seq . + ' for question attempt id ' . $questionattemptid); + } + + foreach ($step->get_all_data() as $name => $value) { + $data = new stdClass; + $data->attemptstepid = $record->id; + $data->name = addslashes($name); + $data->value = addslashes($value); + insert_record('question_attempt_step_data', $data, false); + } + } + + /** + * Load a {@link question_attempt_step} from the database. + * @param integer $stepid the id of the step to load. + * @param question_attempt_step the step that was loaded. + */ + public function load_question_attempt_step($stepid) { + global $CFG; + $records = get_records_sql(" +SELECT + COALESCE(qasd.id, -1 * qas.id) AS id, + qas.id AS attemptstepid, + qas.questionattemptid, + qas.sequencenumber, + qas.state, + qas.fraction, + qas.timecreated, + qas.userid, + qasd.name, + qasd.value + +FROM {$CFG->prefix}question_attempt_steps qas +LEFT JOIN {$CFG->prefix}question_attempt_step_data qasd ON qasd.attemptstepid = qas.id + +WHERE + qas.id = $stepid + "); + + if (!$records) { + throw new Exception('Failed to load question_attempt_step ' . $stepid); + } + + return question_attempt_step::load_from_records($records, $stepid); + } + + /** + * Load a {@link question_attempt} from the database, including all its + * steps. + * @param integer $questionattemptid the id of the question attempt to load. + * @param question_attempt the question attempt that was loaded. + */ + public function load_question_attempt($questionattemptid) { + global $CFG; + $records = get_records_sql(" +SELECT + COALESCE(qasd.id, -1 * qas.id) AS id, + quba.preferredbehaviour, + qa.id AS questionattemptid, + qa.questionusageid, + qa.slot, + qa.behaviour, + qa.questionid, + qa.maxmark, + qa.minfraction, + qa.flagged, + qa.questionsummary, + qa.rightanswer, + qa.responsesummary, + qa.timemodified, + qas.id AS attemptstepid, + qas.sequencenumber, + qas.state, + qas.fraction, + qas.timecreated, + qas.userid, + qasd.name, + qasd.value + +FROM {$CFG->prefix}question_attempts qa +JOIN {$CFG->prefix}question_usages quba ON quba.id = qa.questionusageid +LEFT JOIN {$CFG->prefix}question_attempt_steps qas ON qas.questionattemptid = qa.id +LEFT JOIN {$CFG->prefix}question_attempt_step_data qasd ON qasd.attemptstepid = qas.id + +WHERE + qa.id = $questionattemptid + +ORDER BY + qas.sequencenumber + "); + + if (!$records) { + throw new Exception('Failed to load question_attempt ' . $questionattemptid); + } + + $record = current($records); + return question_attempt::load_from_records($records, $questionattemptid, + new question_usage_null_observer(), $record->preferredbehaviour); + } + + /** + * Load a {@link question_usage_by_activity} from the database, including + * all its {@link question_attempt}s and all their steps. + * @param integer $qubaid the id of the usage to load. + * @param question_usage_by_activity the usage that was loaded. + */ + public function load_questions_usage_by_activity($qubaid) { + global $CFG; + $records = get_records_sql(" +SELECT + COALESCE(qasd.id, -1 * qas.id) AS id, + quba.id AS qubaid, + quba.contextid, + quba.component, + quba.preferredbehaviour, + qa.id AS questionattemptid, + qa.questionusageid, + qa.slot, + qa.behaviour, + qa.questionid, + qa.maxmark, + qa.minfraction, + qa.flagged, + qa.questionsummary, + qa.rightanswer, + qa.responsesummary, + qa.timemodified, + qas.id AS attemptstepid, + qas.sequencenumber, + qas.state, + qas.fraction, + qas.timecreated, + qas.userid, + qasd.name, + qasd.value + +FROM {$CFG->prefix}question_usages quba +LEFT JOIN {$CFG->prefix}question_attempts qa ON qa.questionusageid = quba.id +LEFT JOIN {$CFG->prefix}question_attempt_steps qas ON qas.questionattemptid = qa.id +LEFT JOIN {$CFG->prefix}question_attempt_step_data qasd ON qasd.attemptstepid = qas.id + +WHERE + quba.id = $qubaid + +ORDER BY + qa.slot, + qas.sequencenumber + "); + + if (!$records) { + throw new Exception('Failed to load questions_usage_by_activity ' . $qubaid); + } + + return question_usage_by_activity::load_from_records($records, $qubaid); + } + + /** + * Load information about the latest state of each question from the database. + * + * @param qubaid_condition $qubaids used to restrict which usages are included + * in the query. See {@link qubaid_condition}. + * @param array $slots A list of slots for the questions you want to konw about. + * @return array of records. See the SQL in this function to see the fields available. + */ + public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots) { + global $CFG; + + list($slottest, $params) = get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000'); + + $records = get_records_sql(" +SELECT + qas.id, + qa.id AS questionattemptid, + qa.questionusageid, + qa.slot, + qa.behaviour, + qa.questionid, + qa.maxmark, + qa.minfraction, + qa.flagged, + qa.questionsummary, + qa.rightanswer, + qa.responsesummary, + qa.timemodified, + qas.id AS attemptstepid, + qas.sequencenumber, + qas.state, + qas.fraction, + qas.timecreated, + qas.userid + +FROM {$qubaids->from_question_attempts('qa')} +JOIN {$CFG->prefix}question_attempt_steps qas ON + qas.id = {$this->latest_step_for_qa_subquery()} + +WHERE + {$qubaids->where()} AND + qa.slot $slottest + "); + + if (!$records) { + $records = array(); + } + + return $records; + } + + /** + * Load summary information about the state of each question in a group of attempts. + * This is used by the quiz manual grading report, to show how many attempts + * at each question need to be graded. + * + * @param qubaid_condition $qubaids used to restrict which usages are included + * in the query. See {@link qubaid_condition}. + * @param array $slots A list of slots for the questions you want to konw about. + * @return array The array keys are slot,qestionid. The values are objects with + * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded, + * $manuallygraded and $all. + */ + public function load_questions_usages_question_state_summary(qubaid_condition $qubaids, $slots) { + global $CFG; + + list($slottest, $params) = get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000'); + + $rs = get_recordset_sql(" +SELECT + qa.slot, + qa.questionid, + q.name, + CASE qas.state + {$this->full_states_to_summary_state_sql()} + END AS summarystate, + COUNT(1) AS numattempts + +FROM {$qubaids->from_question_attempts('qa')} +JOIN {$CFG->prefix}question_attempt_steps qas ON + qas.id = {$this->latest_step_for_qa_subquery()} +JOIN {$CFG->prefix}question q ON q.id = qa.questionid + +WHERE + {$qubaids->where()} AND + qa.slot $slottest + +GROUP BY + qa.slot, + qa.questionid, + q.name, + q.id, + summarystate + +ORDER BY + qa.slot, + qa.questionid, + q.name, + q.id + "); + + if (!$rs) { + throw new moodle_exception('errorloadingdata'); + } + + $results = array(); + while ($row = rs_fetch_next_record($rs)) { + $index = $row->slot . ',' . $row->questionid; + + if (!array_key_exists($index, $results)) { + $res = new stdClass; + $res->slot = $row->slot; + $res->questionid = $row->questionid; + $res->name = $row->name; + $res->inprogress = 0; + $res->needsgrading = 0; + $res->autograded = 0; + $res->manuallygraded = 0; + $res->all = 0; + $results[$index] = $res; + } + + $results[$index]->{$row->summarystate} = $row->numattempts; + $results[$index]->all += $row->numattempts; + } + rs_close($rs); + + return $results; + } + + /** + * Get a list of usage ids where the question with slot $slot, and optionally + * also with question id $questionid, is in summary state $summarystate. Also + * return the total count of such states. + * + * Only a subset of the ids can be returned by using $orderby, $limitfrom and + * $limitnum. A special value 'random' can be passed as $orderby, in which case + * $limitfrom is ignored. + * + * @param qubaid_condition $qubaids used to restrict which usages are included + * in the query. See {@link qubaid_condition}. + * @param integer $slot The slot for the questions you want to konw about. + * @param integer $questionid (optional) Only return attempts that were of this specific question. + * @param string $summarystate the summary state of interest, or 'all'. + * @param string $orderby the column to order by. + * @param integer $limitfrom implements paging of the results. + * Ignored if $orderby = random or $limitnum is null. + * @param integer $limitnum implements paging of the results. null = all. + * @return array with two elements, an array of usage ids, and a count of the total number. + */ + public function load_questions_usages_where_question_in_state( + qubaid_condition $qubaids, $summarystate, $slot, $questionid = null, + $orderby = 'random', $limitfrom = 0, $limitnum = null) { + global $CFG; + + $extrawhere = ''; + if ($questionid) { + $extrawhere .= ' AND qa.questionid = ' . $questionid; + } + if ($summarystate != 'all') { + $test = $this->in_summary_state_test($summarystate); + $extrawhere .= ' AND qas.state ' . $test; + } + + if ($orderby == 'random') { + $sqlorderby = ''; + } else if ($orderby) { + $sqlorderby = 'ORDER BY ' . $orderby; + } else { + $sqlorderby = ''; + } + + // We always want the total count, as well as the partcular list of ids, + // based on the paging and sort order. Becuase the list of ids is never + // going to be too rediculously long. My worst-case scenario is + // 10,000 students in the coures, each doing 5 quiz attempts. That + // is a 50,000 element int => int array, which PHP seems to use 5MB + // memeory to store on a 64 bit server. + $qubaids = get_records_sql_menu(" +SELECT + qa.questionusageid, + 1 + +FROM {$qubaids->from_question_attempts('qa')} +JOIN {$CFG->prefix}question_attempt_steps qas ON + qas.id = {$this->latest_step_for_qa_subquery()} +JOIN {$CFG->prefix}question q ON q.id = qa.questionid + +WHERE + {$qubaids->where()} AND + qa.slot = $slot + $extrawhere + +$sqlorderby + "); + + $qubaids = array_keys($qubaids); + $count = count($qubaids); + + if ($orderby == 'random') { + shuffle($qubaids); + $limitfrom = 0; + } + + if (!is_null($limitnum)) { + $qubaids = array_slice($qubaids, $limitfrom, $limitnum); + } + + return array($qubaids, $count); + } + + /** + * Load a {@link question_usage_by_activity} from the database, including + * all its {@link question_attempt}s and all their steps. + * @param qubaid_condition $qubaids used to restrict which usages are included + * in the query. See {@link qubaid_condition}. + * @param array $slots if null, load info for all quesitions, otherwise only + * load the averages for the specified questions. + */ + public function load_average_marks(qubaid_condition $qubaids, $slots = null) { + global $CFG; + + if (!empty($slots)) { + list($slottest, $params) = get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000'); + $slotwhere = " AND qa.slot $slottest"; + } else { + $slotwhere = ''; + } + + list($statetest) = get_in_or_equal(array( + question_state::$gaveup, + question_state::$gradedwrong, + question_state::$gradedpartial, + question_state::$gradedright, + question_state::$mangaveup, + question_state::$mangrwrong, + question_state::$mangrpartial, + question_state::$mangrright)); + + $records = get_records_sql(" +SELECT + qa.slot, + AVG(COALESCE(qas.fraction, 0)) AS averagefraction, + COUNT(1) AS numaveraged + +FROM {$qubaids->from_question_attempts('qa')} +JOIN {$CFG->prefix}question_attempt_steps qas ON + qas.id = {$this->latest_step_for_qa_subquery()} + +WHERE + {$qubaids->where()} + $slotwhere + AND qas.state $statetest + +GROUP BY qa.slot + +ORDER BY qa.slot + "); + + return $records; + } + + /** + * Load a {@link question_attempt} from the database, including all its + * steps. + * @param integer $questionid the question to load all the attempts fors. + * @param qubaid_condition $qubaids used to restrict which usages are included + * in the query. See {@link qubaid_condition}. + * @return array of question_attempts. + */ + public function load_attempts_at_question($questionid, qubaid_condition $qubaids) { + global $CFG; + $records = get_records_sql(" +SELECT + COALESCE(qasd.id, -1 * qas.id) AS id, + quba.preferredbehaviour, + qa.id AS questionattemptid, + qa.questionusageid, + qa.slot, + qa.behaviour, + qa.questionid, + qa.maxmark, + qa.minfraction, + qa.flagged, + qa.questionsummary, + qa.rightanswer, + qa.responsesummary, + qa.timemodified, + qas.id AS attemptstepid, + qas.sequencenumber, + qas.state, + qas.fraction, + qas.timecreated, + qas.userid, + qasd.name, + qasd.value + +FROM {$qubaids->from_question_attempts('qa')} +JOIN {$CFG->prefix}question_usages quba ON quba.id = qa.questionusageid +LEFT JOIN {$CFG->prefix}question_attempt_steps qas ON qas.questionattemptid = qa.id +LEFT JOIN {$CFG->prefix}question_attempt_step_data qasd ON qasd.attemptstepid = qas.id + +WHERE + {$qubaids->where()} AND + qa.questionid = $questionid + +ORDER BY + quba.id, + qa.id, + qas.sequencenumber + "); + + if (!$records) { + return array(); + } + + $questionattempts = array(); + $record = current($records); + while ($record) { + $questionattempts[$record->questionattemptid] = + question_attempt::load_from_records($records, + $record->questionattemptid, new question_usage_null_observer(), + $record->preferredbehaviour); + $record = current($records); + } + return $questionattempts; + } + + /** + * Update a question_usages row to refect any changes in a usage (but not + * any of its question_attempts. + * @param question_usage_by_activity $quba the usage that has changed. + */ + public function update_questions_usage_by_activity(question_usage_by_activity $quba) { + $record = new stdClass; + $record->id = $quba->get_id(); + $record->contextid = $quba->get_owning_context()->id; + $record->component = addslashes($quba->get_owning_component()); + $record->preferredbehaviour = addslashes($quba->get_preferred_behaviour()); + + if (!update_record('question_usages', $record)) { + throw new Exception('Failed to update question_usage_by_activity ' . $record->id); + } + } + + /** + * Update a question_attempts row to refect any changes in a question_attempt + * (but not any of its steps). + * @param question_attempt $qa the question attempt that has changed. + */ + public function update_question_attempt(question_attempt $qa) { + $record = new stdClass; + $record->id = $qa->get_database_id(); + $record->maxmark = $qa->get_max_mark(); + $record->minfraction = $qa->get_min_fraction(); + $record->flagged = $qa->is_flagged(); + $record->questionsummary = addslashes($qa->get_question_summary()); + $record->rightanswer = addslashes($qa->get_right_answer_summary()); + $record->responsesummary = addslashes($qa->get_response_summary()); + $record->timemodified = time(); + + if (!update_record('question_attempts', $record)) { + throw new Exception('Failed to update question_attempt ' . $record->id); + } + } + + /** + * Delete a question_usage_by_activity and all its associated + * {@link question_attempts} and {@link question_attempt_steps} from the + * database. + * @param string $where a where clause. Becuase of MySQL limitations, you + * must refer to {$CFG->prefix}question_usages.id in full like that. + */ + public function delete_questions_usage_by_activities($where) { + global $CFG; + delete_records_select('question_attempt_step_data', "attemptstepid IN ( + SELECT qas.id + FROM {$CFG->prefix}question_attempts qa + JOIN {$CFG->prefix}question_attempt_steps qas ON qas.questionattemptid = qa.id + JOIN {$CFG->prefix}question_usages ON qa.questionusageid = {$CFG->prefix}question_usages.id + WHERE $where)"); + delete_records_select('question_attempt_steps', "questionattemptid IN ( + SELECT qa.id + FROM {$CFG->prefix}question_attempts qa + JOIN {$CFG->prefix}question_usages ON qa.questionusageid = {$CFG->prefix}question_usages.id + WHERE $where)"); + delete_records_select('question_attempts', "questionusageid IN ( + SELECT id + FROM {$CFG->prefix}question_usages + WHERE $where)"); + delete_records_select('question_usages', $where); + } + + /** + * Delete all the steps for a question attempt. + * @param integer $qaids question_attempt id. + */ + public function delete_steps_for_question_attempts($qaids) { + global $CFG; + if (empty($qaids)) { + return; + } + list($test, $params) = get_in_or_equal($qaids); + delete_records_select('question_attempt_step_data', "attemptstepid IN ( + SELECT qas.id + FROM {$CFG->prefix}question_attempt_steps qas + WHERE questionattemptid $test)"); + delete_records_select('question_attempt_steps', 'questionattemptid ' . $test); + } + + /** + * Delete all the previews for a given question. + * @param integer $questionid question id. + */ + public function delete_previews($questionid) { + global $CFG; + $previews = get_records_sql_menu(" + SELECT DISTINCT quba.id, 1 + FROM {$CFG->prefix}question_usages quba + JOIN {$CFG->prefix}question_attempts qa ON qa.questionusageid = quba.id + WHERE quba.component = 'core_question_preview' AND + qa.questionid = '$questionid'"); + if (empty($previews)) { + return; + } + $this->delete_questions_usage_by_activities( + "{$CFG->prefix}question_usages.id IN (" . + implode(',', array_keys($previews)) . ')'); + } + + /** + * Update the flagged state of a question in the database. + * @param integer $qubaid the question usage id. + * @param integer $questionid the question id. + * @param integer $sessionid the question_attempt id. + * @param boolean $newstate the new state of the flag. true = flagged. + */ + public function update_question_attempt_flag($qubaid, $questionid, $qaid, $newstate) { + if (!record_exists('question_attempts', 'id', $qaid, + 'questionusageid', $qubaid, 'questionid', $questionid)) { + throw new Exception('invalid ids'); + } + + if (!set_field('question_attempts', 'flagged', $newstate, 'id', $qaid)) { + throw new Exception('flag update failed'); + } + } + + /** + * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state + * column to a summary state. Use this like + * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate, + * @param string SQL fragment. + */ + protected function full_states_to_summary_state_sql() { + $sql = ''; + foreach (question_state::get_all() as $state) { + $sql .= "WHEN '$state' THEN '{$state->get_summary_state()}'\n"; + } + return $sql; + } + + /** + * Get the SQL needed to test that question_attempt_steps.state is in a + * state corresponding to $summarystate. + * @param string $summarystate one of + * inprogress, needsgrading, manuallygraded or autograded + * @param boolean $equal if false, do a NOT IN test. Default true. + * @return string SQL fragment. + */ + public function in_summary_state_test($summarystate, $equal = true) { + $states = question_state::get_all_for_summary_state($summarystate); + list($sql, $params) = get_in_or_equal($states, SQL_PARAMS_QM, 'param0000', $equal); + return $sql; + } + + /** + * Change the maxmark for the question_attempt with number in usage $slot + * for all the specified question_attempts. + * @param qubaid_condition $qubaids Selects which usages are updated. + * @param integer $slot the number is usage to affect. + * @param number $newmaxmark the new max mark to set. + */ + public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) { + set_field_select('question_attempts', 'maxmark', $newmaxmark, + "questionusageid {$qubaids->usage_id_in()} AND slot = $slot"); + } + + /** + * Return a subquery that computes the sum of the marks for all the questions + * in a usage. Which useage to compute the sum for is controlled bu the $qubaid + * parameter. + * + * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of + * this method. + * + * @param string $qubaid SQL fragment that controls which usage is summed. + * This might be the name of a column in the outer query. + * @return string SQL code for the subquery. + */ + public function sum_usage_marks_subquery($qubaid) { + global $CFG; + return "SELECT SUM(qa.maxmark * qas.fraction) + FROM {$CFG->prefix}question_attempts qa + JOIN ( + SELECT summarks_qa.id AS questionattemptid, MAX(summarks_qas.id) AS latestid + FROM {$CFG->prefix}question_attempt_steps summarks_qas + JOIN {$CFG->prefix}question_attempts summarks_qa ON summarks_qa.id = summarks_qas.questionattemptid + WHERE summarks_qa.questionusageid = $qubaid + GROUP BY summarks_qa.id + ) lateststepid ON lateststepid.questionattemptid = qa.id + JOIN {$CFG->prefix}question_attempt_steps qas ON qas.id = lateststepid.latestid + WHERE qa.questionusageid = $qubaid + HAVING COUNT(CASE WHEN qas.state = 'needsgrading' THEN 1 ELSE NULL END) = 0"; + } + + public function question_attempt_latest_state_view($alias) { + global $CFG; + return "( + SELECT + {$alias}qa.id AS questionattemptid, + {$alias}qa.questionusageid, + {$alias}qa.slot, + {$alias}qa.behaviour, + {$alias}qa.questionid, + {$alias}qa.maxmark, + {$alias}qa.minfraction, + {$alias}qa.flagged, + {$alias}qa.questionsummary, + {$alias}qa.rightanswer, + {$alias}qa.responsesummary, + {$alias}qa.timemodified, + {$alias}qas.id AS attemptstepid, + {$alias}qas.sequencenumber, + {$alias}qas.state, + {$alias}qas.fraction, + {$alias}qas.timecreated, + {$alias}qas.userid + + FROM {$CFG->prefix}question_attempts {$alias}qa + JOIN {$CFG->prefix}question_attempt_steps {$alias}qas ON + {$alias}qas.id = {$this->latest_step_for_qa_subquery($alias . 'qa.id')} + ) $alias"; + } + + protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') { + global $CFG; + return "( + SELECT MAX(id) + FROM {$CFG->prefix}question_attempt_steps + WHERE questionattemptid = $questionattemptid + )"; + } + + /** + * @param array $questionids of question ids. + * @return boolean whether any of these questions are being used by the question engine. + */ + public static function questions_in_use(array $questionids) { + return record_exists_select('question_attempts', 'questionid IN (' . + implode(',', $questionids) . ')'); + } +} + +/** + * Implementation of the unit of work pattern for the question engine. + * + * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the + * changes to a {@link question_usage_by_activity}, and its constituent parts, + * so that the changes can be saved to the database when {@link save()} is called. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_engine_unit_of_work implements question_usage_observer { + /** @var question_usage_by_activity the usage being tracked. */ + protected $quba; + + /** @var boolean whether any of the fields of the usage have been changed. */ + protected $modified = false; + + /** + * @var array list of number in usage => {@link question_attempt}s that + * were already in the usage, and which have been modified. + */ + protected $attemptsmodified = array(); + + /** + * @var array list of number in usage => {@link question_attempt}s that + * have been added to the usage. + */ + protected $attemptsadded = array(); + + /** + * @var array list of question attempt ids to delete the steps for, before + * inserting new steps. + */ + protected $attemptstodeletestepsfor = array(); + + /** + * @var array list of array(question_attempt_step, question_attempt id, seq number) + * of steps that have been added to question attempts in this usage. + */ + protected $stepsadded = array(); + + /** + * Constructor. + * @param question_usage_by_activity $quba the usage to track. + */ + public function __construct(question_usage_by_activity $quba) { + $this->quba = $quba; + } + + public function notify_modified() { + $this->modified = true; + } + + public function notify_attempt_modified(question_attempt $qa) { + $no = $qa->get_slot(); + if (!array_key_exists($no, $this->attemptsadded)) { + $this->attemptsmodified[$no] = $qa; + } + } + + public function notify_attempt_added(question_attempt $qa) { + $this->attemptsadded[$qa->get_slot()] = $qa; + } + + public function notify_delete_attempt_steps(question_attempt $qa) { + + if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { + return; + } + + $qaid = $qa->get_database_id(); + foreach ($this->stepsadded as $key => $stepinfo) { + if ($stepinfo[1] == $qaid) { + unset($this->stepsadded[$key]); + } + } + + $this->attemptstodeletestepsfor[$qaid] = 1; + } + + public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) { + if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { + return; + } + $this->stepsadded[] = array($step, $qa->get_database_id(), $seq); + } + + /** + * Write all the changes we have recorded to the database. + * @param question_engine_data_mapper $dm the mapper to use to update the database. + */ + public function save(question_engine_data_mapper $dm) { + $dm->delete_steps_for_question_attempts(array_keys($this->attemptstodeletestepsfor)); + foreach ($this->stepsadded as $stepinfo) { + list($step, $questionattemptid, $seq) = $stepinfo; + $dm->insert_question_attempt_step($step, $questionattemptid, $seq); + } + foreach ($this->attemptsadded as $qa) { + $dm->insert_question_attempt($qa); + } + foreach ($this->attemptsmodified as $qa) { + $dm->update_question_attempt($qa); + } + if ($this->modified) { + $dm->update_questions_usage_by_activity($this->quba); + } + } +} + + +/** + * This class represents a restriction on the set of question_usage ids to include + * in a larger database query. Depending of the how you are going to restrict the + * list of usages, construct an appropriate subclass. + * + * If $qubaids is an instance of this class, example usage might be + * + * SELECT qa.id, qa.maxmark + * FROM $qubaids->from_question_attempts('qa') + * WHERE $qubaids->where() AND qa.slot = 1 + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class qubaid_condition { + + /** + * @return string the SQL that needs to go in the FROM clause when trying + * to select records from the 'question_attempts' table based on the + * qubaid_condition. + */ + public abstract function from_question_attempts($alias); + + /** @return string the SQL that needs to go in the where clause. */ + public abstract function where(); + + /** + * @return the params needed by a query that uses + * {@link from_question_attempts()} and {@link where()}. + */ + public abstract function from_where_params(); + + /** + * @return string SQL that can use used in a WHERE qubaid IN (...) query. + * This method returns the "IN (...)" part. + */ + public abstract function usage_id_in(); + + /** + * @return the params needed by a query that uses {@link usage_id_in()}. + */ + public abstract function usage_id_in_params(); +} + + +/** + * This class represents a restriction on the set of question_usage ids to include + * in a larger database query based on an explicit list of ids. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qubaid_list extends qubaid_condition { + /** @var array of ids. */ + protected $qubaids; + protected $columntotest = null; + protected $params; + + /** + * Constructor. + * @param array $qubaids of question usage ids. + */ + public function __construct(array $qubaids) { + $this->qubaids = $qubaids; + } + + public function from_question_attempts($alias) { + global $CFG; + $this->columntotest = $alias . '.questionusageid'; + return "{$CFG->prefix}question_attempts $alias"; + } + + public function where() { + global $DB; + + if (is_null($this->columntotest)) { + throw new coding_exception('Must call another method that before where().'); + } + if (empty($this->qubaids)) { + return '1 = 0'; + } + list($where, $this->params) = $DB->get_in_or_equal($this->qubaids, SQL_PARAMS_NAMED, 'qubaid0000'); + + return "{$this->columntotest} {$this->usage_id_in()}"; + } + + public function from_where_params() { + return $this->params; + } + + public function usage_id_in() { + global $DB; + + if (empty($this->qubaids)) { + return '= 0'; + } + list($where, $this->params) = $DB->get_in_or_equal($this->qubaids, SQL_PARAMS_NAMED, 'qubaid0000'); + return $where; + } + + public function usage_id_in_params() { + return $this->params; + } +} + + +/** + * This class represents a restriction on the set of question_usage ids to include + * in a larger database query based on JOINing to some other tables. + * + * The general form of the query is something like + * + * SELECT qa.id, qa.maxmark + * FROM $from + * JOIN {$CFG->prefix}question_attempts qa ON qa.questionusageid = $usageidcolumn + * WHERE $where AND qa.slot = 1 + * + * where $from, $usageidcolumn and $where are the arguments to the constructor. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qubaid_join extends qubaid_condition { + public $from; + public $usageidcolumn; + public $where; + public $params; + + /** + * Constructor. The meaning of the arguments is explained in the class comment. + * @param string $from SQL fragemnt to go in the FROM clause. + * @param string $usageidcolumn the column in $from that should be + * made equal to the usageid column in the JOIN clause. + * @param string $where SQL fragment to go in the where clause. + */ + public function __construct($from, $usageidcolumn, $where = '', $params = array()) { + $this->from = $from; + $this->usageidcolumn = $usageidcolumn; + $this->params = $params; + if (empty($where)) { + $where = '1 = 1'; + } + $this->where = $where; + } + + public function from_question_attempts($alias) { + global $CFG; + return "$this->from + JOIN {$CFG->prefix}question_attempts {$alias} ON " . + "{$alias}.questionusageid = $this->usageidcolumn"; + } + + public function where() { + return $this->where; + } + + public function from_where_params() { + return $this->params; + } + + public function usage_id_in() { + return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)"; + } + + public function usage_id_in_params() { + return $this->params; + } +} diff --git a/question/engine/lib.php b/question/engine/lib.php new file mode 100644 index 00000000000..f3a9e005b6b --- /dev/null +++ b/question/engine/lib.php @@ -0,0 +1,2877 @@ +. + +/** + * This defines the core classes of the Moodle question engine. + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/states.php'); +require_once(dirname(__FILE__) . '/datalib.php'); +require_once(dirname(__FILE__) . '/renderer.php'); +require_once(dirname(__FILE__) . '/bank.php'); +require_once(dirname(__FILE__) . '/../type/questiontype.php'); +require_once(dirname(__FILE__) . '/../type/questionbase.php'); +require_once(dirname(__FILE__) . '/../type/rendererbase.php'); +require_once(dirname(__FILE__) . '/../behaviour/behaviourbase.php'); +require_once(dirname(__FILE__) . '/../behaviour/rendererbase.php'); +require_once($CFG->libdir . '/questionlib.php'); + + +/** + * This static class provides access to the other question engine classes. + * + * It provides functions for managing question behaviours), and for + * creating, loading, saving and deleting {@link question_usage_by_activity}s, + * which is the main class that is used by other code that wants to use questions. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_engine { + /** @var array behaviour name => 1. Records which behaviours have been loaded. */ + private static $loadedbehaviours = array(); + + /** + * Create a new {@link question_usage_by_activity}. The usage is + * created in memory. If you want it to persist, you will need to call + * {@link save_questions_usage_by_activity()}. + * + * @param string $component the plugin creating this attempt. For example mod_quiz. + * @param object $context the context this usage belongs to. + * @return question_usage_by_activity the newly created object. + */ + public static function make_questions_usage_by_activity($component, $context) { + return new question_usage_by_activity($component, $context); + } + + /** + * Load a {@link question_usage_by_activity} from the database, based on its id. + * @param integer $qubaid the id of the usage to load. + * @return question_usage_by_activity loaded from the database. + */ + public static function load_questions_usage_by_activity($qubaid) { + $dm = new question_engine_data_mapper(); + return $dm->load_questions_usage_by_activity($qubaid); + } + + /** + * Save a {@link question_usage_by_activity} to the database. This works either + * if the usage was newly created by {@link make_questions_usage_by_activity()} + * or loaded from the database using {@link load_questions_usage_by_activity()} + * @param question_usage_by_activity the usage to save. + */ + public static function save_questions_usage_by_activity(question_usage_by_activity $quba) { + $dm = new question_engine_data_mapper(); + $observer = $quba->get_observer(); + if ($observer instanceof question_engine_unit_of_work) { + $observer->save($dm); + } else { + $dm->insert_questions_usage_by_activity($quba); + } + } + + /** + * Delete a {@link question_usage_by_activity} from the database, based on its id. + * @param integer $qubaid the id of the usage to delete. + */ + public static function delete_questions_usage_by_activity($qubaid) { + global $CFG; + self::delete_questions_usage_by_activities($CFG->prefix . 'question_usages.id = ' . $qubaid); + } + + /** + * Delete a {@link question_usage_by_activity} from the database, based on its id. + * @param integer $qubaid the id of the usage to delete. + */ + public static function delete_questions_usage_by_activities($where) { + $dm = new question_engine_data_mapper(); + $dm->delete_questions_usage_by_activities($where); + } + + /** + * Change the maxmark for the question_attempt with number in usage $slot + * for all the specified question_attempts. + * @param qubaid_condition $qubaids Selects which usages are updated. + * @param integer $slot the number is usage to affect. + * @param number $newmaxmark the new max mark to set. + */ + public static function set_max_mark_in_attempts(qubaid_condition $qubaids, + $slot, $newmaxmark) { + $dm = new question_engine_data_mapper(); + $dm->set_max_mark_in_attempts($qubaids, $slot, $newmaxmark); + } + + /** + * @param array $questionids of question ids. + * @return boolean whether any of these questions are being used by the question engine. + */ + public static function questions_in_use(array $questionids) { + $dm = new question_engine_data_mapper(); + return $dm->questions_in_use($questionids); + } + + /** + * Create an archetypal behaviour for a particular question attempt. + * Used by {@link question_definition::make_behaviour()}. + * + * @param string $preferredbehaviour the type of model required. + * @param question_attempt $qa the question attempt the model will process. + * @return question_behaviour an instance of appropriate behaviour class. + */ + public static function make_archetypal_behaviour($preferredbehaviour, question_attempt $qa) { + question_engine::load_behaviour_class($preferredbehaviour); + $class = 'qbehaviour_' . $preferredbehaviour; + if (!constant($class . '::IS_ARCHETYPAL')) { + throw new Exception('The requested behaviour is not actually an archetypal one.'); + } + return new $class($qa, $preferredbehaviour); + } + + /** + * @param string $behaviour the name of a behaviour. + * @return array of {@link question_display_options} field names, that are + * not relevant to this behaviour before a 'finish' action. + */ + public static function get_behaviour_unused_display_options($behaviour) { + self::load_behaviour_class($behaviour); + $class = 'qbehaviour_' . $behaviour; + if (!method_exists($class, 'get_unused_display_options')) { + return question_behaviour::get_unused_display_options(); + } + return call_user_func(array($class, 'get_unused_display_options')); + } + + /** + * Create an behaviour for a particular type. If that type cannot be + * found, return an instance of qbehaviour_missing. + * + * Normally you should use {@link make_archetypal_behaviour()}, or + * call the constructor of a particular model class directly. This method + * is only intended for use by {@link question_attempt::load_from_records()}. + * + * @param string $behaviour the type of model to create. + * @param question_attempt $qa the question attempt the model will process. + * @param string $preferredbehaviour the preferred behaviour for the containing usage. + * @return question_behaviour an instance of appropriate behaviour class. + */ + public static function make_behaviour($behaviour, question_attempt $qa, $preferredbehaviour) { + try { + self::load_behaviour_class($behaviour); + } catch (Exception $e) { + question_engine::load_behaviour_class('missing'); + return new qbehaviour_missing($qa, $preferredbehaviour); + } + $class = 'qbehaviour_' . $behaviour; + return new $class($qa, $preferredbehaviour); + } + + /** + * Load the behaviour class(es) belonging to a particular model. That is, + * include_once('/question/behaviour/' . $behaviour . '/behaviour.php'), with a bit + * of checking. + * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'. + */ + public static function load_behaviour_class($behaviour) { + global $CFG; + if (isset(self::$loadedbehaviours[$behaviour])) { + return; + } + $file = $CFG->dirroot . '/question/behaviour/' . $behaviour . '/behaviour.php'; + if (!is_readable($file)) { + throw new Exception('Unknown question behaviour ' . $behaviour); + } + include_once($file); + self::$loadedbehaviours[$behaviour] = 1; + } + + /** + * Return an array where the keys are the internal names of the archetypal + * behaviours, and the values are a human-readable name. An + * archetypal behaviour is one that is suitable to pass the name of to + * {@link question_usage_by_activity::set_preferred_behaviour()}. + * + * @return array model name => lang string for this behaviour name. + */ + public static function get_archetypal_behaviours() { + $archetypes = array(); + $behaviours = get_list_of_plugins('question/behaviour'); + foreach ($behaviours as $path) { + $behaviour = basename($path); + self::load_behaviour_class($behaviour); + $plugin = 'qbehaviour_' . $behaviour; + if (constant($plugin . '::IS_ARCHETYPAL')) { + $archetypes[$behaviour] = self::get_behaviour_name($behaviour); + } + } + asort($archetypes, SORT_LOCALE_STRING); + return $archetypes; + } + + /** + * Return an array where the keys are the internal names of the behaviours + * in preferred order and the values are a human-readable name. + * + * @param array $archetypes, array of behaviours + * @param string $questionbehavioursorder, a comma separated list of behaviour names + * @param string $questionbehavioursdisabled, a comma separated list of behaviour names + * @param string $currentbahaviour, current behaviour name + * @return array model name => lang string for this behaviour name. + */ + public static function sort_behaviours($archetypes, $questionbehavioursorder, + $questionbehavioursdisabled, $currentbahaviour) { + $behaviourorder = array(); + $behaviourdisabled = array(); + + // Get disabled behaviours + if ($questionbehavioursdisabled) { + $behaviourdisabledtemp = preg_split('/[\s,;]+/', $questionbehavioursdisabled); + } else { + $behaviourdisabledtemp = array(); + } + + if ($questionbehavioursorder) { + $behaviourordertemp = preg_split('/[\s,;]+/', $questionbehavioursorder); + } else { + $behaviourordertemp = array(); + } + + foreach ($behaviourdisabledtemp as $key) { + if (array_key_exists($key, $archetypes)) { + // Do not disable the current behaviour + if ($key != $currentbahaviour) { + $behaviourdisabled[$key] = $archetypes[$key]; + } + } + } + + // Get behaviours in preferred order + foreach ($behaviourordertemp as $key) { + if (array_key_exists($key, $archetypes)) { + $behaviourorder[$key] = $archetypes[$key]; + } + } + // Get the rest of behaviours and sort them alphabetically + $leftover = array_diff_key($archetypes, $behaviourdisabled, $behaviourorder); + asort($leftover, SORT_LOCALE_STRING); + + // Set up the final order to be displayed + $finalorder = $behaviourorder + $leftover; + return $finalorder; + } + + /** + * Return an array where the keys are the internal names of the behaviours + * in preferred order and the values are a human-readable name. + * + * @param string $currentbahaviour + * @return array model name => lang string for this behaviour name. + */ + public static function get_behaviour_options($currentbahaviour) { + global $CFG; + $archetypes = self::get_archetypal_behaviours(); + + // If no admin setting return all behavious + if (!$CFG->questionbehavioursdisabled && !$CFG->questionbehavioursorder) { + return $archetypes; + } + + return self::sort_behaviours($archetypes, $CFG->questionbehavioursorder, + $CFG->questionbehavioursdisabled, $currentbahaviour); + } + + /** + * Get the translated name of an behaviour, for display in the UI. + * @param string $behaviour the internal name of the model. + * @return string name from the current language pack. + */ + public static function get_behaviour_name($behaviour) { + return get_string($behaviour, 'qbehaviour_' . $behaviour); + } + + /** + * Returns the valid choices for the number of decimal places for showing + * question marks. For use in the user interface. + * @return array suitable for passing to {@link choose_from_menu()} or similar. + */ + public static function get_dp_options() { + return question_display_options::get_dp_options(); + } + + public static function initialise_js() { + return question_flags::initialise_js(); + } +} + + +/** + * This class contains all the options that controls how a question is displayed. + * + * Normally, what will happen is that the calling code will set up some display + * options to indicate what sort of question display it wants, and then before the + * question is rendered, the behaviour will be given a chance to modify the + * display options, so that, for example, A question that is finished will only + * be shown read-only, and a question that has not been submitted will not have + * any sort of feedback displayed. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_display_options { + /**#@+ @var integer named constants for the values that most of the options take. */ + const HIDDEN = 0; + const VISIBLE = 1; + const EDITABLE = 2; + /**#@-*/ + + /**#@+ @var integer named constants for the {@link $marks} option. */ + const MAX_ONLY = 1; + const MARK_AND_MAX = 2; + /**#@-*/ + + /** + * @var integer maximum value for the {@link $markpd} option. This is + * effectively set by the database structure, which uses NUMBER(12,7) columns + * for question marks/fractions. + */ + const MAX_DP = 7; + + /** + * @var boolean whether the question should be displayed as a read-only review, + * or in an active state where you can change the answer. + */ + public $readonly = false; + + /** + * @var boolean whether the question type should output hidden form fields + * to reset any incorrect parts of the resonse to blank. + */ + public $clearwrong = false; + + /** + * Should the student have what they got right and wrong clearly indicated. + * This includes the green/red hilighting of the bits of their response, + * whether the one-line summary of the current state of the question says + * correct/incorrect or just answered. + * @var integer {@link question_display_options::HIDDEN} or + * {@link question_display_options::VISIBLE} + */ + public $correctness = self::VISIBLE; + + /** + * The the mark and/or the maximum available mark for this question be visible? + * @var integer {@link question_display_options::HIDDEN}, + * {@link question_display_options::MAX_ONLY} or {@link question_display_options::MARK_AND_MAX} + */ + public $marks = self::MARK_AND_MAX; + + /** @var number of decimal places to use when formatting marks for output. */ + public $markdp = 2; + + /** + * Should the flag this question UI element be visible, and if so, should the + * flag state be changable? + * @var integer {@link question_display_options::HIDDEN}, + * {@link question_display_options::VISIBLE} or {@link question_display_options::EDITABLE} + */ + public $flags = self::VISIBLE; + + /** + * Should the specific feedback be visible. + * @var integer {@link question_display_options::HIDDEN} or + * {@link question_display_options::VISIBLE} + */ + public $feedback = self::VISIBLE; + + /** + * For questions with a number of sub-parts (like matching, or + * multiple-choice, multiple-reponse) display the number of sub-parts that + * were correct. + * @var integer {@link question_display_options::HIDDEN} or + * {@link question_display_options::VISIBLE} + */ + public $numpartscorrect = self::VISIBLE; + + /** + * Should the general feedback be visible? + * @var integer {@link question_display_options::HIDDEN} or + * {@link question_display_options::VISIBLE} + */ + public $generalfeedback = self::VISIBLE; + + /** + * Should the automatically generated display of what the correct answer is + * be visible? + * @var integer {@link question_display_options::HIDDEN} or + * {@link question_display_options::VISIBLE} + */ + public $rightanswer = self::VISIBLE; + + /** + * Should the manually added marker's comment be visible. Should the link for + * adding/editing the comment be there. + * @var integer {@link question_display_options::HIDDEN}, + * {@link question_display_options::VISIBLE}, or {@link question_display_options::EDITABLE}. + * Editable means that form fields are displayed inline. + */ + public $manualcomment = self::VISIBLE; + + /** + * Should we show a 'Make comment or override grade' link? + * @var string base URL for the edit comment script, which will be shown if + * $manualcomment = self::VISIBLE. + */ + public $manualcommentlink = null; + + /** + * Used in places like the question history table, to show a link to review + * this question in a certain state. If blank, a link is not shown. + * @var string base URL for a review question script. + */ + public $questionreviewlink = null; + + /** + * Should the history of previous question states table be visible? + * @var integer {@link question_display_options::HIDDEN} or + * {@link question_display_options::VISIBLE} + */ + public $history = self::HIDDEN; + + /** + * Set all the feedback-related fields {@link $feedback}, {@link generalfeedback}, + * {@link rightanswer} and {@link manualcomment} to + * {@link question_display_options::HIDDEN}. + */ + public function hide_all_feedback() { + $this->feedback = self::HIDDEN; + $this->numpartscorrect = self::HIDDEN; + $this->generalfeedback = self::HIDDEN; + $this->rightanswer = self::HIDDEN; + $this->manualcomment = self::HIDDEN; + $this->correctness = self::HIDDEN; + } + + /** + * Returns the valid choices for the number of decimal places for showing + * question marks. For use in the user interface. + * + * Calling code should probably use {@link question_engine::get_dp_options()} + * rather than calling this method directly. + * + * @return array suitable for passing to {@link choose_from_menu()} or similar. + */ + public static function get_dp_options() { + $options = array(); + for ($i = 0; $i <= self::MAX_DP; $i += 1) { + $options[$i] = $i; + } + return $options; + } +} + + +/** + * Contains the logic for handling question flags. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_flags { + /** + * Get the checksum that validates that a toggle request is valid. + * @param integer $qubaid the question usage id. + * @param integer $questionid the question id. + * @param integer $sessionid the question_attempt id. + * @param object $user the user. If null, defaults to $USER. + * @return string that needs to be sent to question/toggleflag.php for it to work. + */ + protected static function get_toggle_checksum($qubaid, $questionid, $qaid, $user = null) { + if (is_null($user)) { + global $USER; + $user = $USER; + } + return md5($qubaid . "_" . $user->secret . "_" . $questionid . "_" . $qaid); + } + + /** + * Get the postdata that needs to be sent to question/toggleflag.php to change the flag state. + * You need to append &newstate=0/1 to this. + * @return the post data to send. + */ + public static function get_postdata(question_attempt $qa) { + $qaid = $qa->get_database_id(); + $qubaid = $qa->get_usage_id(); + $qid = $qa->get_question()->id; + $checksum = self::get_toggle_checksum($qubaid, $qid, $qaid); + return "qaid=$qaid&qubaid=$qubaid&qid=$qid&checksum=$checksum&sesskey=" . sesskey(); + } + + /** + * If the request seems valid, update the flag state of a question attempt. + * Throws exceptions if this is not a valid update request. + * @param integer $qubaid the question usage id. + * @param integer $questionid the question id. + * @param integer $sessionid the question_attempt id. + * @param string $checksum checksum, as computed by {@link get_toggle_checksum()} + * corresponding to the last three arguments. + * @param boolean $newstate the new state of the flag. true = flagged. + */ + public static function update_flag($qubaid, $questionid, $qaid, $checksum, $newstate) { + // Check the checksum - it is very hard to know who a question session belongs + // to, so we require that checksum parameter is matches an md5 hash of the + // three ids and the users username. Since we are only updating a flag, that + // probably makes it sufficiently difficult for malicious users to toggle + // other users flags. + if ($checksum != question_flags::get_toggle_checksum($qubaid, $questionid, $qaid)) { + throw new Exception('checksum failure'); + } + + $dm = new question_engine_data_mapper(); + $dm->update_question_attempt_flag($qubaid, $questionid, $qaid, $newstate); + } + + public static function initialise_js() { + global $CFG; + + require_js(array('yui_yahoo','yui_dom','yui_event','yui_connection')); + require_js($CFG->wwwroot . '/question/qengine.js'); + + $config = array( + 'actionurl' => $CFG->wwwroot . '/question/toggleflag.php', + 'flagicon' => $CFG->pixpath . '/i/flagged.png', + 'unflagicon' => $CFG->pixpath . '/i/unflagged.png', + 'flagtooltip' => get_string('clicktoflag', 'question'), + 'unflagtooltip' => get_string('clicktounflag', 'question'), + 'flaggedalt' => get_string('flagged', 'question'), + 'unflaggedalt' => get_string('notflagged', 'question'), + ); + return print_js_config($config, 'qengine_config', true); + } +} + + +class question_out_of_sequence_exception extends moodle_exception { + function __construct($qubaid, $slot, $postdata) { + if ($postdata == null) { + $postdata = data_submitted(); + } + parent::__construct('submissionoutofsequence', 'question', '', null, + "QUBAid: $qubaid, slot: $slot, post data: " . print_r($postdata, true)); + } +} + + +/** + * This class keeps track of a group of questions that are being attempted, + * and which state, and so on, each one is currently in. + * + * A quiz attempt or a lesson attempt could use an instance of this class to + * keep track of all the questions in the attempt and process student submissions. + * It is basically a collection of {@question_attempt} objects. + * + * The questions being attempted as part of this usage are identified by an integer + * that is passed into many of the methods as $slot. ($question->id is not + * used so that the same question can be used more than once in an attempt.) + * + * Normally, calling code should be able to do everything it needs to be calling + * methods of this class. You should not normally need to get individual + * {@question_attempt} objects and play around with their inner workind, in code + * that it outside the quetsion engine. + * + * Instances of this class correspond to rows in the question_usages table. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_usage_by_activity { + /** + * @var integer|string the id for this usage. If this usage was loaded from + * the database, then this is the database id. Otherwise a unique random + * string is used. + */ + protected $id = null; + + /** + * @var string name of an archetypal behaviour, that should be used + * by questions in this usage if possible. + */ + protected $preferredbehaviour = null; + + /** @var object the context this usage belongs to. */ + protected $context; + + /** @var string plugin name of the plugin this usage belongs to. */ + protected $owningcomponent; + + /** @var array {@link question_attempt}s that make up this usage. */ + protected $questionattempts = array(); + + /** @var question_usage_observer that tracks changes to this usage. */ + protected $observer; + + /** + * Create a new instance. Normally, calling code should use + * {@link question_engine::make_questions_usage_by_activity()} or + * {@link question_engine::load_questions_usage_by_activity()} rather than + * calling this constructor directly. + * + * @param string $component the plugin creating this attempt. For example mod_quiz. + * @param object $context the context this usage belongs to. + */ + public function __construct($component, $context) { + $this->owningcomponent = $component; + $this->context = $context; + $this->observer = new question_usage_null_observer(); + } + + /** + * @param string $behaviour the name of an archetypal behaviour, that should + * be used by questions in this usage if possible. + */ + public function set_preferred_behaviour($behaviour) { + $this->preferredbehaviour = $behaviour; + $this->observer->notify_modified(); + } + + /** @return string the name of the preferred behaviour. */ + public function get_preferred_behaviour() { + return $this->preferredbehaviour; + } + + /** @return stdClass the context this usage belongs to. */ + public function get_owning_context() { + return $this->context; + } + + /** @return string the name of the plugin that owns this attempt. */ + public function get_owning_component() { + return $this->owningcomponent; + } + + /** @return integer|string If this usage came from the database, then the id + * from the question_usages table is returned. Otherwise a random string is + * returned. */ + public function get_id() { + if (is_null($this->id)) { + $this->id = random_string(10); + } + return $this->id; + } + + /** @return question_usage_observer that is tracking changes made to this usage. */ + public function get_observer() { + return $this->observer; + } + + /** + * For internal use only. Used by {@link question_engine_data_mapper} to set + * the id when a usage is saved to the database. + * @param integer $id the newly determined id for this usage. + */ + public function set_id_from_database($id) { + $this->id = $id; + foreach ($this->questionattempts as $qa) { + $qa->set_usage_id($id); + } + } + + /** + * Add another question to this usage. + * + * The added question is not started until you call {@link start_question()} + * on it. + * + * @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, $question->defaultmark is used. + * @return integer the number used to identify this question within this usage. + */ + public function add_question(question_definition $question, $maxmark = null) { + $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark); + if (count($this->questionattempts) == 0) { + $this->questionattempts[1] = $qa; + } else { + $this->questionattempts[] = $qa; + } + $qa->set_number_in_usage(end(array_keys($this->questionattempts))); + $this->observer->notify_attempt_added($qa); + return $qa->get_slot(); + } + + /** + * Get the question_definition for a question in this attempt. + * @param integer $slot the number used to identify this question within this usage. + * @return question_definition the requested question object. + */ + public function get_question($slot) { + return $this->get_question_attempt($slot)->get_question(); + } + + /** @return array all the identifying numbers of all the questions in this usage. */ + public function get_question_numbers() { + return array_keys($this->questionattempts); + } + + /** @return integer the identifying number of the first question that was added to this usage. */ + public function get_first_question_number() { + reset($this->questionattempts); + return key($this->questionattempts); + } + + /** @return integer the number of questions that are currently in this usage. */ + public function question_count() { + return count($this->questionattempts); + } + + /** + * Note the part of the {@link question_usage_by_activity} comment that explains + * that {@link question_attempt} objects should be considered part of the inner + * workings of the question engine, and should not, if possible, be accessed directly. + * + * @return question_attempt_iterator for iterating over all the questions being + * attempted. as part of this usage. + */ + public function get_attempt_iterator() { + return new question_attempt_iterator($this); + } + + /** + * Check whether $number actually corresponds to a question attempt that is + * part of this usage. Throws an exception if not. + * + * @param integer $slot a number allegedly identifying a question within this usage. + */ + protected function check_slot($slot) { + if (!array_key_exists($slot, $this->questionattempts)) { + throw new exception("There is no question_attempt number $slot in this attempt."); + } + } + + /** + * Note the part of the {@link question_usage_by_activity} comment that explains + * that {@link question_attempt} objects should be considered part of the inner + * workings of the question engine, and should not, if possible, be accessed directly. + * + * @param integer $slot the number used to identify this question within this usage. + * @return question_attempt the corresponding {@link question_attempt} object. + */ + public function get_question_attempt($slot) { + $this->check_slot($slot); + return $this->questionattempts[$slot]; + } + + /** + * Get the current state of the attempt at a question. + * @param integer $slot the number used to identify this question within this usage. + * @return question_state. + */ + public function get_question_state($slot) { + return $this->get_question_attempt($slot)->get_state(); + } + + /** + * @param integer $slot the number used to identify this question within this usage. + * @param boolean $showcorrectness Whether right/partial/wrong states should + * be distinguised. + * @return string A brief textual description of the current state. + */ + public function get_question_state_string($slot, $showcorrectness) { + return $this->get_question_attempt($slot)->get_state_string($showcorrectness); + } + + /** + * Get the time of the most recent action performed on a question. + * @param integer $slot the number used to identify this question within this usage. + * @return integer timestamp. + */ + public function get_question_action_time($slot) { + return $this->get_question_attempt($slot)->get_last_action_time(); + } + + /** + * Get the current fraction awarded for the attempt at a question. + * @param integer $slot the number used to identify this question within this usage. + * @return number|null The current fraction for this question, or null if one has + * not been assigned yet. + */ + public function get_question_fraction($slot) { + return $this->get_question_attempt($slot)->get_fraction(); + } + + /** + * Get the current mark awarded for the attempt at a question. + * @param integer $slot the number used to identify this question within this usage. + * @return number|null The current mark for this question, or null if one has + * not been assigned yet. + */ + public function get_question_mark($slot) { + return $this->get_question_attempt($slot)->get_mark(); + } + + /** + * Get the maximum mark possible for the attempt at a question. + * @param integer $slot the number used to identify this question within this usage. + * @return number the available marks for this question. + */ + public function get_question_max_mark($slot) { + return $this->get_question_attempt($slot)->get_max_mark(); + } + + /** + * Get the current mark awarded for the attempt at a question. + * @param integer $slot the number used to identify this question within this usage. + * @return number|null The current mark for this question, or null if one has + * not been assigned yet. + */ + public function get_total_mark() { + $mark = 0; + foreach ($this->questionattempts as $qa) { + if ($qa->get_state() == question_state::$needsgrading) { + return null; + } + $mark += $qa->get_mark(); + } + return $mark; + } + + /** + * @return string a simple textual summary of the question that was asked. + */ + public function get_question_summary($slot) { + return $this->get_question_attempt($slot)->get_question_summary(); + } + + /** + * @return string a simple textual summary of response given. + */ + public function get_response_summary($slot) { + return $this->get_question_attempt($slot)->get_response_summary(); + } + + /** + * @return string a simple textual summary of the correct resonse. + */ + public function get_right_answer_summary($slot) { + return $this->get_question_attempt($slot)->get_right_answer_summary(); + } + + /** + * Get the {@link core_question_renderer}, in collaboration with appropriate + * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the + * HTML to display this question. + * @param integer $slot the number used to identify this question within this usage. + * @param question_display_options $options controls how the question is rendered. + * @param string|null $number The question number to display. 'i' is a special + * value that gets displayed as Information. Null means no number is displayed. + * @return string HTML fragment representing the question. + */ + public function render_question($slot, $options, $number = null) { + return $this->get_question_attempt($slot)->render($options, $number); + } + + /** + * Generate any bits of HTML that needs to go in the tag when this question + * is displayed in the body. + * @param integer $slot the number used to identify this question within this usage. + * @return string HTML fragment. + */ + public function render_question_head_html($slot) { + return $this->get_question_attempt($slot)->render_head_html(); + } + + /** + * Like {@link render_question()} but displays the question at the past step + * indicated by $seq, rather than showing the latest step. + * + * @param integer $slot the number used to identify this question within this usage. + * @param integer $seq the seq number of the past state to display. + * @param question_display_options $options controls how the question is rendered. + * @param string|null $number The question number to display. 'i' is a special + * value that gets displayed as Information. Null means no number is displayed. + * @return string HTML fragment representing the question. + */ + public function render_question_at_step($slot, $seq, $options, $number = null) { + return $this->get_question_attempt($slot)->render_at_step($seq, $options, $number, $this->preferredbehaviour); + } + + /** + * Replace a particular question_attempt with a different one. + * + * For internal use only. Used when reloading the state of a question from the + * database. + * + * @param array $records Raw records loaded from the database. + * @param integer $questionattemptid The id of the question_attempt to extract. + * @return question_attempt The newly constructed question_attempt_step. + */ + public function replace_loaded_question_attempt_info($slot, $qa) { + $this->check_slot($slot); + $this->questionattempts[$slot] = $qa; + } + + /** + * You should probably not use this method in code outside the question engine. + * The main reason for exposing it was for the benefit of unit tests. + * @param integer $slot the number used to identify this question within this usage. + * @return string return the prefix that is pre-pended to field names in the HTML + * that is output. + */ + public function get_field_prefix($slot) { + return $this->get_question_attempt($slot)->get_field_prefix(); + } + + /** + * Start the attempt at a question that has been added to this usage. + * @param integer $slot the number used to identify this question within this usage. + */ + public function start_question($slot) { + $qa = $this->get_question_attempt($slot); + $qa->start($this->preferredbehaviour); + $this->observer->notify_attempt_modified($qa); + } + + /** + * Start the attempt at all questions that has been added to this usage. + */ + public function start_all_questions() { + foreach ($this->questionattempts as $qa) { + $qa->start($this->preferredbehaviour); + $this->observer->notify_attempt_modified($qa); + } + } + + /** + * Start the attempt at a question, starting from the point where the previous + * question_attempt $oldqa had reached. This is used by the quiz 'Each attempt + * builds on last' mode. + * @param integer $slot the number used to identify this question within this usage. + * @param question_attempt $oldqa a previous attempt at this quetsion that + * defines the starting point. + */ + public function start_question_based_on($slot, question_attempt $oldqa) { + $qa = $this->get_question_attempt($slot); + $qa->start_based_on($oldqa); + $this->observer->notify_attempt_modified($qa); + } + + /** + * Process all the question actions in the current request. + * + * If there is a parameter slots included in the post data, then only + * those question numbers will be processed, otherwise all questions in this + * useage will be. + * + * This function also does {@link update_question_flags()}. + * + * @param integer $timestamp optional, use this timestamp as 'now'. + * @param array $postdata optional, only intended for testing. Use this data + * instead of the data from $_POST. + */ + public function process_all_actions($timestamp = null, $postdata = null) { + $slots = question_attempt::get_submitted_var('slots', PARAM_SEQUENCE, $postdata); + if (is_null($slots)) { + $slots = $this->get_question_numbers(); + } else if (!$slots) { + $slots = array(); + } else { + $slots = explode(',', $slots); + } + foreach ($slots as $slot) { + $this->validate_sequence_number($slot, $postdata); + $submitteddata = $this->extract_responses($slot, $postdata); + $this->process_action($slot, $submitteddata, $timestamp); + } + $this->update_question_flags($postdata); + } + + /** + * Get the submitted data from the current request that belongs to this + * particular question. + * + * @param integer $slot the number used to identify this question within this usage. + * @param $postdata optional, only intended for testing. Use this data + * instead of the data from $_POST. + * @return array submitted data specific to this question. + */ + public function extract_responses($slot, $postdata = null) { + return $this->get_question_attempt($slot)->get_submitted_data($postdata); + } + + /** + * Process a specific action on a specific question. + * @param integer $slot the number used to identify this question within this usage. + * @param $submitteddata the submitted data that constitutes the action. + */ + public function process_action($slot, $submitteddata, $timestamp = null) { + $qa = $this->get_question_attempt($slot); + $qa->process_action($submitteddata, $timestamp); + $this->observer->notify_attempt_modified($qa); + } + + /** + * Check that the sequence number, that detects weird things like the student + * clicking back, is OK. + * @param integer $slot the number used to identify this question within this usage. + * @param array $submitteddata the submitted data that constitutes the action. + */ + public function validate_sequence_number($slot, $postdata = null) { + $qa = $this->get_question_attempt($slot); + $sequencecheck = question_attempt::get_submitted_var( + $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata); + if (!is_null($sequencecheck) && $sequencecheck != $qa->get_num_steps()) { + throw new question_out_of_sequence_exception($this->id, $slot, $postdata); + } + } + /** + * Update the flagged state for all question_attempts in this usage, if their + * flagged state was changed in the request. + * + * @param $postdata optional, only intended for testing. Use this data + * instead of the data from $_POST. + */ + public function update_question_flags($postdata = null) { + foreach ($this->questionattempts as $qa) { + $flagged = question_attempt::get_submitted_var( + $qa->get_flag_field_name(), PARAM_BOOL, $postdata); + if (!is_null($flagged) && $flagged != $qa->is_flagged()) { + $qa->set_flagged($flagged); + } + } + + } + + /** + * Get the correct response to a particular question. Passing the results of + * this method to {@link process_action()} will probably result in full marks. + * @param integer $slot the number used to identify this question within this usage. + * @return array that constitutes a correct response to this question. + */ + public function get_correct_response($slot) { + return $this->get_question_attempt($slot)->get_correct_response(); + } + + /** + * Finish the active phase of an attempt at a question. + * + * This is an external act of finishing the attempt. Think, for example, of + * the 'Submit all and finish' button in the quiz. Some behaviours, + * (for example, immediatefeedback) give a way of finishing the active phase + * of a question attempt as part of a {@link process_action()} call. + * + * After the active phase is over, the only changes possible are things like + * manual grading, or changing the flag state. + * + * @param integer $slot the number used to identify this question within this usage. + */ + public function finish_question($slot, $timestamp = null) { + $qa = $this->get_question_attempt($slot); + $qa->finish($timestamp); + $this->observer->notify_attempt_modified($qa); + } + + /** + * Finish the active phase of an attempt at a question. See {@link finish_question()} + * for a fuller description of what 'finish' means. + */ + public function finish_all_questions($timestamp = null) { + foreach ($this->questionattempts as $qa) { + $qa->finish($timestamp); + $this->observer->notify_attempt_modified($qa); + } + } + + /** + * Perform a manual grading action on a question attempt. + * @param integer $slot the number used to identify this question within this usage. + * @param string $comment the comment being added to the question attempt. + * @param number $mark the mark that is being assigned. Can be null to just + * add a comment. + */ + public function manual_grade($slot, $comment, $mark) { + $qa = $this->get_question_attempt($slot); + $qa->manual_grade($comment, $mark); + $this->observer->notify_attempt_modified($qa); + } + + /** + * Regrade a question in this usage. This replays the sequence of submitted + * actions to recompute the outcomes. + * @param integer $slot the number used to identify this question within this usage. + * @param boolean $finished whether the question attempt should be forced to be finished + * after the regrade, or whether it may still be in progress (default false). + * @param number $newmaxmark (optional) if given, will change the max mark while regrading. + */ + public function regrade_question($slot, $finished = false, $newmaxmark = null) { + $oldqa = $this->get_question_attempt($slot); + if (is_null($newmaxmark)) { + $newmaxmark = $oldqa->get_max_mark(); + } + + $this->observer->notify_delete_attempt_steps($oldqa); + + $newqa = new question_attempt($oldqa->get_question(), $oldqa->get_usage_id(), + $this->observer, $newmaxmark); + $newqa->set_database_id($oldqa->get_database_id()); + $newqa->regrade($oldqa, $finished); + + $this->questionattempts[$slot] = $newqa; + $this->observer->notify_attempt_modified($newqa); + } + + /** + * Regrade all the questions in this usage (without changing their max mark). + * @param boolean $finished whether each question should be forced to be finished + * after the regrade, or whether it may still be in progress (default false). + */ + public function regrade_all_questions($finished = false) { + foreach ($this->questionattempts as $slot => $notused) { + $this->regrade_question($slot, $finished); + } + } + + /** + * Create a question_usage_by_activity from records loaded from the database. + * + * For internal use only. + * + * @param array $records Raw records loaded from the database. + * @param integer $questionattemptid The id of the question_attempt to extract. + * @return question_attempt The newly constructed question_attempt_step. + */ + public static function load_from_records(&$records, $qubaid) { + $record = current($records); + while ($record->qubaid != $qubaid) { + $record = next($records); + if (!$record) { + throw new Exception("Question usage $qubaid not found in the database."); + } + } + + $quba = new question_usage_by_activity($record->component, + get_context_instance_by_id($record->contextid)); + $quba->set_id_from_database($record->qubaid); + $quba->set_preferred_behaviour($record->preferredbehaviour); + + $quba->observer = new question_engine_unit_of_work($quba); + + while ($record && $record->qubaid == $qubaid && !is_null($record->slot)) { + $quba->questionattempts[$record->slot] = + question_attempt::load_from_records($records, + $record->questionattemptid, $quba->observer, + $quba->get_preferred_behaviour()); + $record = current($records); + } + + return $quba; + } +} + + +/** + * A class abstracting access to the + * {@link question_usage_by_activity::$questionattempts} array. + * + * This class snapshots the list of {@link question_attempts} to iterate over + * when it is created. If a question is added to the usage mid-iteration, it + * will now show up. + * + * To create an instance of this class, use + * {@link question_usage_by_activity::get_attempt_iterator()} + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt_iterator implements Iterator, ArrayAccess { + /** @var question_usage_by_activity that we are iterating over. */ + protected $quba; + /** @var array of question numbers. */ + protected $slots; + + /** + * To create an instance of this class, use {@link question_usage_by_activity::get_attempt_iterator()}. + * @param $quba the usage to iterate over. + */ + public function __construct(question_usage_by_activity $quba) { + $this->quba = $quba; + $this->slots = $quba->get_question_numbers(); + $this->rewind(); + } + + /** @return question_attempt_step */ + public function current() { + return $this->offsetGet(current($this->slots)); + } + /** @return integer */ + public function key() { + return current($this->slots); + } + public function next() { + next($this->slots); + } + public function rewind() { + reset($this->slots); + } + /** @return boolean */ + public function valid() { + return current($this->slots) !== false; + } + + /** @return boolean */ + public function offsetExists($slot) { + return in_array($slot, $this->slots); + } + /** @return question_attempt_step */ + public function offsetGet($slot) { + return $this->quba->get_question_attempt($slot); + } + public function offsetSet($slot, $value) { + throw new Exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.'); + } + public function offsetUnset($slot) { + throw new Exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.'); + } +} + + +/** + * Tracks an attempt at one particular question in a {@link question_usage_by_activity}. + * + * Most calling code should need to access objects of this class. They should be + * able to do everything through the usage interface. This class is an internal + * implementation detail of the question engine. + * + * Instances of this class correspond to rows in the question_attempts table, and + * a collection of {@link question_attempt_steps}. Question inteaction models and + * question types do work with question_attempt objects. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt { + /** + * @var string this is a magic value that question types can return from + * {@link question_definition::get_expected_data()}. + */ + const USE_RAW_DATA = 'use raw data'; + + /** + * @var string special value used by manual grading because {@link PARAM_NUMBER} + * converts '' to 0. + */ + const PARAM_MARK = 'parammark'; + + /** @var integer if this attempts is stored in the question_attempts table, the id of that row. */ + protected $id = null; + + /** @var integer|string the id of the question_usage_by_activity we belong to. */ + protected $usageid; + + /** @var integer the number used to identify this question_attempt within the usage. */ + protected $slot = null; + + /** + * @var question_behaviour the behaviour controlling this attempt. + * null until {@link start()} is called. + */ + protected $behaviour = null; + + /** @var question_definition the question this is an attempt at. */ + protected $question; + + /** @var number the maximum mark that can be scored at this question. */ + protected $maxmark; + + /** + * @var number the minimum fraction that can be scored at this question, so + * the minimum mark is $this->minfraction * $this->maxmark. + */ + protected $minfraction = null; + + /** + * @var string plain text summary of the variant of the question the + * student saw. Intended for reporting purposes. + */ + protected $questionsummary = null; + + /** + * @var string plain text summary of the response the student gave. + * Intended for reporting purposes. + */ + protected $responsesummary = null; + + /** + * @var string plain text summary of the correct response to this question + * variant the student saw. The format should be similar to responsesummary. + * Intended for reporting purposes. + */ + protected $rightanswer = null; + + /** @var array of {@link question_attempt_step}s. The steps in this attempt. */ + protected $steps = array(); + + /** @var boolean whether the user has flagged this attempt within the usage. */ + protected $flagged = false; + + /** @var question_usage_observer tracks changes to the useage this attempt is part of.*/ + protected $observer; + + /**#@+ + * Constants used by the intereaction models to indicate whether the current + * pending step should be kept or discarded. + */ + const KEEP = true; + const DISCARD = false; + /**#@-*/ + + /** + * Create a new {@link question_attempt}. Normally you should create question_attempts + * indirectly, by calling {@link question_usage_by_activity::add_question()}. + * + * @param question_definition $question the question this is an attempt at. + * @param integer|string $usageid The id of the + * {@link question_usage_by_activity} we belong to. Used by {@link get_field_prefix()}. + * @param question_usage_observer $observer tracks changes to the useage this + * attempt is part of. (Optional, a {@link question_usage_null_observer} is + * used if one is not passed. + * @param number $maxmark the maximum grade for this question_attempt. If not + * passed, $question->defaultmark is used. + */ + public function __construct(question_definition $question, $usageid, + question_usage_observer $observer = null, $maxmark = null) { + $this->question = $question; + $this->usageid = $usageid; + if (is_null($observer)) { + $observer = new question_usage_null_observer(); + } + $this->observer = $observer; + if (!is_null($maxmark)) { + $this->maxmark = $maxmark; + } else { + $this->maxmark = $question->defaultmark; + } + } + + /** @return question_definition the question this is an attempt at. */ + public function get_question() { + return $this->question; + } + + /** + * Set the number used to identify this question_attempt within the usage. + * For internal use only. + * @param integer $slot + */ + public function set_number_in_usage($slot) { + $this->slot = $slot; + } + + /** @return integer the number used to identify this question_attempt within the usage. */ + public function get_slot() { + return $this->slot; + } + + /** + * @return integer the id of row for this question_attempt, if it is stored in the + * database. null if not. + */ + public function get_database_id() { + return $this->id; + } + + /** + * For internal use only. Set the id of the corresponding database row. + * @param integer $id the id of row for this question_attempt, if it is + * stored in the database. + */ + public function set_database_id($id) { + $this->id = $id; + } + + /** @return integer|string the id of the {@link question_usage_by_activity} we belong to. */ + public function get_usage_id() { + return $this->usageid; + } + + /** + * Set the id of the {@link question_usage_by_activity} we belong to. + * For internal use only. + * @param integer|string the new id. + */ + public function set_usage_id($usageid) { + $this->usageid = $usageid; + } + + /** @return string the name of the behaviour that is controlling this attempt. */ + public function get_behaviour_name() { + return $this->behaviour->get_name(); + } + + /** + * For internal use only. + * @return question_behaviour the behaviour that is controlling this attempt. + */ + public function get_behaviour() { + return $this->behaviour; + } + + /** + * Set the flagged state of this question. + * @param boolean $flagged the new state. + */ + public function set_flagged($flagged) { + $this->flagged = $flagged; + $this->observer->notify_attempt_modified($this); + } + + /** @return boolean whether this question is currently flagged. */ + public function is_flagged() { + return $this->flagged; + } + + /** + * Get the name (in the sense a HTML name="" attribute, or a $_POST variable + * name) to use for the field that indicates whether this question is flagged. + * + * @return string The field name to use. + */ + public function get_flag_field_name() { + return $this->get_control_field_name('flagged'); + } + + /** + * Get the name (in the sense a HTML name="" attribute, or a $_POST variable + * name) to use for a question_type variable belonging to this question_attempt. + * + * See the comment on {@link question_attempt_step} for an explanation of + * question type and behaviour variables. + * + * @param $varname The short form of the variable name. + * @return string The field name to use. + */ + public function get_qt_field_name($varname) { + return $this->get_field_prefix() . $varname; + } + + /** + * Get the name (in the sense a HTML name="" attribute, or a $_POST variable + * name) to use for a question_type variable belonging to this question_attempt. + * + * See the comment on {@link question_attempt_step} for an explanation of + * question type and behaviour variables. + * + * @param $varname The short form of the variable name. + * @return string The field name to use. + */ + public function get_behaviour_field_name($varname) { + return $this->get_field_prefix() . '-' . $varname; + } + + /** + * Get the name (in the sense a HTML name="" attribute, or a $_POST variable + * name) to use for a control variables belonging to this question_attempt. + * + * Examples are :sequencecheck and :flagged + * + * @param $varname The short form of the variable name. + * @return string The field name to use. + */ + public function get_control_field_name($varname) { + return $this->get_field_prefix() . ':' . $varname; + } + + /** + * Get the prefix added to variable names to give field names for this + * question attempt. + * + * You should not use this method directly. This is an implementation detail + * anyway, but if you must access it, use {@link question_usage_by_activity::get_field_prefix()}. + * + * @param $varname The short form of the variable name. + * @return string The field name to use. + */ + public function get_field_prefix() { + return 'q' . $this->usageid . ':' . $this->slot . '_'; + } + + /** + * Get one of the steps in this attempt. + * For internal/test code use only. + * @param integer $i the step number. + * @return question_attempt_step + */ + public function get_step($i) { + if ($i < 0 || $i >= count($this->steps)) { + throw new Exception('Index out of bounds in question_attempt::get_step.'); + } + return $this->steps[$i]; + } + + /** + * Get the number of steps in this attempt. + * For internal/test code use only. + * @return integer the number of steps we currently have. + */ + public function get_num_steps() { + return count($this->steps); + } + + /** + * Return the latest step in this question_attempt. + * For internal/test code use only. + * @return question_attempt_step + */ + public function get_last_step() { + if (count($this->steps) == 0) { + return new question_null_step(); + } + return end($this->steps); + } + + /** + * @return question_attempt_step_iterator for iterating over the steps in + * this attempt, in order. + */ + public function get_step_iterator() { + return new question_attempt_step_iterator($this); + } + + /** + * The same as {@link get_step_iterator()}. However, for a + * {@link question_attempt_with_restricted_history} this returns the full + * list of steps, while {@link get_step_iterator()} returns only the + * limited history. + * @return question_attempt_step_iterator for iterating over the steps in + * this attempt, in order. + */ + public function get_full_step_iterator() { + return $this->get_step_iterator(); + } + + /** + * @return question_attempt_reverse_step_iterator for iterating over the steps in + * this attempt, in reverse order. + */ + public function get_reverse_step_iterator() { + return new question_attempt_reverse_step_iterator($this); + } + + /** + * Get the qt data from the latest step that has any qt data. Return $default + * array if it is no step has qt data. + * + * @param string $name the name of the variable to get. + * @param mixed default the value to return no step has qt data. + * (Optional, defaults to an empty array.) + * @return array|mixed the data, or $default if there is not any. + */ + public function get_last_qt_data($default = array()) { + foreach ($this->get_reverse_step_iterator() as $step) { + $response = $step->get_qt_data(); + if (!empty($response)) { + return $response; + } + } + return $default; + } + + /** + * Get the latest value of a particular question type variable. That is, get + * the value from the latest step that has it set. Return null if it is not + * set in any step. + * + * @param string $name the name of the variable to get. + * @param mixed default the value to return in the variable has never been set. + * (Optional, defaults to null.) + * @return mixed string value, or $default if it has never been set. + */ + public function get_last_qt_var($name, $default = null) { + foreach ($this->get_reverse_step_iterator() as $step) { + if ($step->has_qt_var($name)) { + return $step->get_qt_var($name); + } + } + return $default; + } + + /** + * Get the latest value of a particular behaviour variable. That is, + * get the value from the latest step that has it set. Return null if it is + * not set in any step. + * + * @param string $name the name of the variable to get. + * @param mixed default the value to return in the variable has never been set. + * (Optional, defaults to null.) + * @return mixed string value, or $default if it has never been set. + */ + public function get_last_behaviour_var($name, $default = null) { + foreach ($this->get_reverse_step_iterator() as $step) { + if ($step->has_behaviour_var($name)) { + return $step->get_behaviour_var($name); + } + } + return $default; + } + + /** + * Get the current state of this question attempt. That is, the state of the + * latest step. + * @return question_state + */ + public function get_state() { + return $this->get_last_step()->get_state(); + } + + /** + * @param boolean $showcorrectness Whether right/partial/wrong states should + * be distinguised. + * @return string A brief textual description of the current state. + */ + public function get_state_string($showcorrectness) { + return $this->behaviour->get_state_string($showcorrectness); + } + + /** + * @return integer the timestamp of the most recent step in this question attempt. + */ + public function get_last_action_time() { + return $this->get_last_step()->get_timecreated(); + } + + /** + * Get the current fraction of this question attempt. That is, the fraction + * of the latest step, or null if this question has not yet been graded. + * @return number the current fraction. + */ + public function get_fraction() { + return $this->get_last_step()->get_fraction(); + } + + /** @return boolean whether this question attempt has a non-zero maximum mark. */ + public function has_marks() { + // Since grades are stored in the database as NUMBER(12,7). + return $this->maxmark >= 0.00000005; + } + + /** + * @return number the current mark for this question. + * {@link get_fraction()} * {@link get_max_mark()}. + */ + public function get_mark() { + return $this->fraction_to_mark($this->get_fraction()); + } + + /** + * This is used by the manual grading code, particularly in association with + * validation. If there is a mark submitted in the request, then use that, + * otherwise use the latest mark for this question. + * @return number the current mark for this question. + * {@link get_fraction()} * {@link get_max_mark()}. + */ + public function get_current_manual_mark() { + $mark = self::get_submitted_var($this->get_behaviour_field_name('mark'), question_attempt::PARAM_MARK); + if (is_null($mark)) { + return $this->get_mark(); + } else { + return $mark; + } + } + + /** + * @param number|null $fraction a fraction. + * @return number|null the corresponding mark. + */ + public function fraction_to_mark($fraction) { + if (is_null($fraction)) { + return null; + } + return $fraction * $this->maxmark; + } + + /** @return number the maximum mark possible for this question attempt. */ + public function get_max_mark() { + return $this->maxmark; + } + + /** @return number the maximum mark possible for this question attempt. */ + public function get_min_fraction() { + if (is_null($this->minfraction)) { + throw new Exception('This question_attempt has not been started yet, the min fraction is not yet konwn.'); + } + return $this->minfraction; + } + + /** + * The current mark, formatted to the stated number of decimal places. Uses + * {@link format_float()} to format floats according to the current locale. + * @param integer $dp number of decimal places. + * @return string formatted mark. + */ + public function format_mark($dp) { + return $this->format_fraction_as_mark($this->get_fraction(), $dp); + } + + /** + * The current mark, formatted to the stated number of decimal places. Uses + * {@link format_float()} to format floats according to the current locale. + * @param integer $dp number of decimal places. + * @return string formatted mark. + */ + public function format_fraction_as_mark($fraction, $dp) { + return format_float($this->fraction_to_mark($fraction), $dp); + } + + /** + * The maximum mark for this question attempt, formatted to the stated number + * of decimal places. Uses {@link format_float()} to format floats according + * to the current locale. + * @param integer $dp number of decimal places. + * @return string formatted maximum mark. + */ + public function format_max_mark($dp) { + return format_float($this->maxmark, $dp); + } + + /** + * Return the hint that applies to the question in its current state, or null. + * @return question_hint|null + */ + public function get_applicable_hint() { + return $this->behaviour->get_applicable_hint(); + } + + /** + * Produce a plain-text summary of what the user did during a step. + * @param question_attempt_step $step the step in quetsion. + * @return string a summary of what was done during that step. + */ + public function summarise_action(question_attempt_step $step) { + return $this->behaviour->summarise_action($step); + } + + /** + * Get the {@link core_question_renderer}, in collaboration with appropriate + * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the + * HTML to display this question attempt in its current state. + * @param question_display_options $options controls how the question is rendered. + * @param string|null $number The question number to display. + * @return string HTML fragment representing the question. + */ + public function render($options, $number) { + $qoutput = renderer_factory::get_renderer('core', 'question'); + $qtoutput = $this->question->get_renderer(); + return $this->behaviour->render($options, $number, $qoutput, $qtoutput); + } + + /** + * Generate any bits of HTML that needs to go in the tag when this question + * attempt is displayed in the body. + * @return string HTML fragment. + */ + public function render_head_html() { + return $this->question->get_renderer()->head_code($this) . + $this->behaviour->get_renderer()->head_code($this); + } + + /** + * Like {@link render_question()} but displays the question at the past step + * indicated by $seq, rather than showing the latest step. + * + * @param integer $seq the seq number of the past state to display. + * @param question_display_options $options controls how the question is rendered. + * @param string|null $number The question number to display. 'i' is a special + * value that gets displayed as Information. Null means no number is displayed. + * @return string HTML fragment representing the question. + */ + public function render_at_step($seq, $options, $number, $preferredbehaviour) { + $restrictedqa = new question_attempt_with_restricted_history($this, $seq, $preferredbehaviour); + return $restrictedqa->render($options, $number); + } + + /** + * Add a step to this question attempt. + * @param question_attempt_step $step the new step. + */ + protected function add_step(question_attempt_step $step) { + $this->steps[] = $step; + end($this->steps); + $this->observer->notify_step_added($step, $this, key($this->steps)); + } + + /** + * Start (or re-start) this question attempt. + * + * You should not call this method directly. Call + * {@link question_usage_by_activity::start_question()} instead. + * + * @param string|question_behaviour $preferredbehaviour the name of the + * desired archetypal behaviour, or an actual model instance. + * @param $submitteddata optional, used when re-starting to keep the same initial state. + * @param $timestamp optional, the timstamp to record for this action. Defaults to now. + * @param $userid optional, the user to attribute this action to. Defaults to the current user. + */ + public function start($preferredbehaviour, $submitteddata = array(), $timestamp = null, $userid = null) { + // Initialise the behaviour. + if (is_string($preferredbehaviour)) { + $this->behaviour = + $this->question->make_behaviour($this, $preferredbehaviour); + } else { + $class = get_class($preferredbehaviour); + $this->behaviour = new $class($this, $preferredbehaviour); + } + + // Record the minimum fraction. + $this->minfraction = $this->behaviour->get_min_fraction(); + + // Initialise the first step. + $firststep = new question_attempt_step($submitteddata, $timestamp, $userid); + $firststep->set_state(question_state::$todo); + $this->behaviour->init_first_step($firststep); + $this->add_step($firststep); + + // Record questionline and correct answer. + // TODO we should only really do this for new attempts, not when called + // via load_from_records. + $this->questionsummary = $this->behaviour->get_question_summary(); + $this->rightanswer = $this->behaviour->get_right_answer_summary(); + } + + /** + * Start this question attempt, starting from the point that the previous + * attempt $oldqa had reached. + * + * You should not call this method directly. Call + * {@link question_usage_by_activity::start_question_based_on()} instead. + * + * @param question_attempt $oldqa a previous attempt at this quetsion that + * defines the starting point. + */ + public function start_based_on(question_attempt $oldqa) { + $this->start($oldqa->behaviour, $oldqa->get_resume_data()); + } + + /** + * Used by {@link start_based_on()} to get the data needed to start a new + * attempt from the point this attempt has go to. + * @return array name => value pairs. + */ + protected function get_resume_data() { + return $this->behaviour->get_resume_data(); + } + + /** + * Get a particular parameter from the current request. A wrapper round + * {@link optional_param()}, except that the results is returned without + * slashes. + * @param string $name the paramter name. + * @param integer $type one of the PARAM_... constants. + * @param array $postdata (optional, only inteded for testing use) take the + * data from this array, instead of from $_POST. + * @return mixed the requested value. + */ + public static function get_submitted_var($name, $type, $postdata = null) { + // Special case to work around PARAM_NUMBER converting '' to 0. + if ($type == self::PARAM_MARK) { + $mark = self::get_submitted_var($name, PARAM_RAW_TRIMMED, $postdata); + if ($mark === '') { + return $mark; + } else { + return self::get_submitted_var($name, PARAM_NUMBER, $postdata); + } + } + + if (is_null($postdata)) { + $var = optional_param($name, null, $type); + } else if (array_key_exists($name, $postdata)) { + $var = clean_param($postdata[$name], $type); + } else { + $var = null; + } + if (is_string($var)) { + $var = stripslashes($var); + } + return $var; + } + + /** + * Get any data from the request that matches the list of expected params. + * @param array $expected variable name => PARAM_... constant. + * @param string $extraprefix '-' or ''. + * @return array name => value. + */ + protected function get_expected_data($expected, $postdata, $extraprefix) { + $submitteddata = array(); + foreach ($expected as $name => $type) { + $value = self::get_submitted_var( + $this->get_field_prefix() . $extraprefix . $name, $type, $postdata); + if (!is_null($value)) { + $submitteddata[$extraprefix . $name] = $value; + } + } + return $submitteddata; + } + + /** + * Get all the submitted question type data for this question, whithout checking + * that it is valid or cleaning it in any way. + * @return array name => value. + */ + protected function get_all_submitted_qt_vars($postdata) { + if (is_null($postdata)) { + $postdata = $_POST; + } + + $pattern = '/^' . preg_quote($this->get_field_prefix()) . '[^-:]/'; + $prefixlen = strlen($this->get_field_prefix()); + + $submitteddata = array(); + foreach ($_POST as $name => $value) { + if (preg_match($pattern, $name)) { + $submitteddata[substr($name, $prefixlen)] = $value; + } + } + + return $submitteddata; + } + + /** + * Get all the sumbitted data belonging to this question attempt from the + * current request. + * @param array $postdata (optional, only inteded for testing use) take the + * data from this array, instead of from $_POST. + * @return array name => value pairs that could be passed to {@link process_action()}. + */ + public function get_submitted_data($postdata = null) { + $submitteddata = $this->get_expected_data( + $this->behaviour->get_expected_data(), $postdata, '-'); + + $expected = $this->behaviour->get_expected_qt_data(); + if ($expected === self::USE_RAW_DATA) { + $submitteddata += $this->get_all_submitted_qt_vars($postdata); + } else { + $submitteddata += $this->get_expected_data($expected, $postdata, ''); + } + return $submitteddata; + } + + /** + * Get a set of response data for this question attempt that would get the + * best possible mark. + * @return array name => value pairs that could be passed to {@link process_action()}. + */ + public function get_correct_response() { + $response = $this->question->get_correct_response(); + $imvars = $this->behaviour->get_correct_response(); + foreach ($imvars as $name => $value) { + $response['-' . $name] = $value; + } + return $response; + } + + /** + * Change the quetsion summary. Note, that this is almost never necessary. + * This method was only added to work around a limitation of the Opaque + * protocol, which only sends questionLine at the end of an attempt. + * @param $questionsummary the new summary to set. + */ + public function set_question_summary($questionsummary) { + $this->questionsummary = $questionsummary; + $this->observer->notify_attempt_modified($this); + } + + /** + * @return string a simple textual summary of the question that was asked. + */ + public function get_question_summary() { + return $this->questionsummary; + } + + /** + * @return string a simple textual summary of response given. + */ + public function get_response_summary() { + return $this->responsesummary; + } + + /** + * @return string a simple textual summary of the correct resonse. + */ + public function get_right_answer_summary() { + return $this->rightanswer; + } + + /** + * Perform the action described by $submitteddata. + * @param array $submitteddata the submitted data the determines the action. + * @param integer $timestamp the time to record for the action. (If not given, use now.) + * @param integer $userid the user to attribute the aciton to. (If not given, use the current user.) + */ + public function process_action($submitteddata, $timestamp = null, $userid = null) { + $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid); + if ($this->behaviour->process_action($pendingstep) == self::KEEP) { + $this->add_step($pendingstep); + if ($pendingstep->response_summary_changed()) { + $this->responsesummary = $pendingstep->get_new_response_summary(); + } + } + } + + /** + * Perform a finish action on this question attempt. This corresponds to an + * external finish action, for example the user pressing Submit all and finish + * in the quiz, rather than using one of the controls that is part of the + * question. + * + * @param integer $timestamp the time to record for the action. (If not given, use now.) + * @param integer $userid the user to attribute the aciton to. (If not given, use the current user.) + */ + public function finish($timestamp = null, $userid = null) { + $this->process_action(array('-finish' => 1), $timestamp, $userid); + } + + /** + * Perform a regrade. This replays all the actions from $oldqa into this + * attempt. + * @param question_attempt $oldqa the attempt to regrade. + * @param boolean $finished whether the question attempt should be forced to be finished + * after the regrade, or whether it may still be in progress (default false). + */ + public function regrade(question_attempt $oldqa, $finished) { + $first = true; + foreach ($oldqa->get_step_iterator() as $step) { + if ($first) { + $first = false; + $this->start($oldqa->behaviour, $step->get_all_data(), + $step->get_timecreated(), $step->get_user_id()); + } else { + $this->process_action($step->get_submitted_data(), + $step->get_timecreated(), $step->get_user_id()); + } + } + if ($finished) { + $this->finish(); + } + } + + /** + * Perform a manual grading action on this attempt. + * @param $comment the comment being added. + * @param $mark the new mark. (Optional, if not given, then only a comment is added.) + * @param integer $timestamp the time to record for the action. (If not given, use now.) + * @param integer $userid the user to attribute the aciton to. (If not given, use the current user.) + * @return unknown_type + */ + public function manual_grade($comment, $mark, $timestamp = null, $userid = null) { + $submitteddata = array('-comment' => $comment); + if (!is_null($mark)) { + $submitteddata['-mark'] = $mark; + $submitteddata['-maxmark'] = $this->maxmark; + } + $this->process_action($submitteddata, $timestamp, $userid); + } + + /** @return boolean Whether this question attempt has had a manual comment added. */ + public function has_manual_comment() { + foreach ($this->steps as $step) { + if ($step->has_behaviour_var('comment')) { + return true; + } + } + return false; + } + + /** + * @return string the most recent manual comment that was added to this question. + * null, if none. + */ + public function get_manual_comment() { + foreach ($this->get_reverse_step_iterator() as $step) { + if ($step->has_behaviour_var('comment')) { + return $step->get_behaviour_var('comment'); + } + } + return null; + } + + /** + * @return array subpartid => object with fields + * ->responseclassid the + * ->response the actual response the student gave to this part, as a string. + * ->fraction the credit awarded for this subpart, may be null. + * returns an empty array if no analysis is possible. + */ + public function classify_response() { + return $this->behaviour->classify_response(); + } + + /** + * Create a question_attempt_step from records loaded from the database. + * + * For internal use only. + * + * @param array $records Raw records loaded from the database. + * @param integer $questionattemptid The id of the question_attempt to extract. + * @return question_attempt The newly constructed question_attempt_step. + */ + public static function load_from_records(&$records, $questionattemptid, + question_usage_observer $observer, $preferredbehaviour) { + $record = current($records); + while ($record->questionattemptid != $questionattemptid) { + $record = next($records); + if (!$record) { + throw new Exception("Question attempt $questionattemptid not found in the database."); + } + } + + try { + $question = question_bank::load_question($record->questionid); + } catch (Exception $e) { + // The question must have been deleted somehow. Create a missing + // question to use in its place. + $question = question_bank::get_qtype('missingtype')->make_deleted_instance( + $record->questionid, $record->maxmark + 0); + } + + $qa = new question_attempt($question, $record->questionusageid, null, $record->maxmark + 0); + $qa->set_database_id($record->questionattemptid); + $qa->set_number_in_usage($record->slot); + $qa->minfraction = $record->minfraction + 0; + $qa->set_flagged($record->flagged); + $qa->questionsummary = $record->questionsummary; + $qa->rightanswer = $record->rightanswer; + $qa->responsesummary = $record->responsesummary; + $qa->timemodified = $record->timemodified; + + $qa->behaviour = question_engine::make_behaviour( + $record->behaviour, $qa, $preferredbehaviour); + + $i = 0; + while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) { + $qa->steps[$i] = question_attempt_step::load_from_records($records, $record->attemptstepid); + if ($i == 0) { + $question->init_first_step($qa->steps[0]); + } + $i++; + $record = current($records); + } + + $qa->observer = $observer; + + return $qa; + } +} + + +/** + * This subclass of question_attempt pretends that only part of the step history + * exists. It is used for rendering the question in past states. + * + * All methods that try to modify the question_attempt throw exceptions. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt_with_restricted_history extends question_attempt { + /** + * @var question_attempt the underlying question_attempt. + */ + protected $baseqa; + + /** + * Create a question_attempt_with_restricted_history + * @param question_attempt $baseqa The question_attempt to make a restricted version of. + * @param integer $lastseq the index of the last step to include. + * @param string $preferredbehaviour the preferred behaviour. It is slightly + * annoyting that this needs to be passed, but unavoidable for now. + */ + public function __construct(question_attempt $baseqa, $lastseq, $preferredbehaviour) { + if ($lastseq < 0 || $lastseq >= $baseqa->get_num_steps()) { + throw new coding_exception('$seq out of range', $seq); + } + + $this->baseqa = $baseqa; + $this->steps = array_slice($baseqa->steps, 0, $lastseq + 1); + $this->observer = new question_usage_null_observer(); + + // This should be a straight copy of all the remaining fields. + $this->id = $baseqa->id; + $this->usageid = $baseqa->usageid; + $this->slot = $baseqa->slot; + $this->question = $baseqa->question; + $this->maxmark = $baseqa->maxmark; + $this->minfraction = $baseqa->minfraction; + $this->questionsummary = $baseqa->questionsummary; + $this->responsesummary = $baseqa->responsesummary; + $this->rightanswer = $baseqa->rightanswer; + $this->flagged = $baseqa->flagged; + + // Except behaviour, where we need to create a new one. + $this->behaviour = question_engine::make_behaviour( + $baseqa->get_behaviour_name(), $this, $preferredbehaviour); + } + + public function get_full_step_iterator() { + return $this->baseqa->get_step_iterator(); + } + + protected function add_step(question_attempt_step $step) { + coding_exception('Cannot modify a question_attempt_with_restricted_history.'); + } + public function process_action($submitteddata, $timestamp = null, $userid = null) { + coding_exception('Cannot modify a question_attempt_with_restricted_history.'); + } + public function start($preferredbehaviour, $submitteddata = array(), $timestamp = null, $userid = null) { + coding_exception('Cannot modify a question_attempt_with_restricted_history.'); + } + + public function set_database_id($id) { + coding_exception('Cannot modify a question_attempt_with_restricted_history.'); + } + public function set_flagged($flagged) { + coding_exception('Cannot modify a question_attempt_with_restricted_history.'); + } + public function set_number_in_usage($slot) { + coding_exception('Cannot modify a question_attempt_with_restricted_history.'); + } + public function set_question_summary($questionsummary) { + coding_exception('Cannot modify a question_attempt_with_restricted_history.'); + } + public function set_usage_id($usageid) { + coding_exception('Cannot modify a question_attempt_with_restricted_history.'); + } +} + + +/** + * A class abstracting access to the {@link question_attempt::$states} array. + * + * This is actively linked to question_attempt. If you add an new step + * mid-iteration, then it will be included. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt_step_iterator implements Iterator, ArrayAccess { + /** @var question_attempt the question_attempt being iterated over. */ + protected $qa; + /** @var integer records the current position in the iteration. */ + protected $i; + + /** + * Do not call this constructor directly. + * Use {@link question_attempt::get_step_iterator()}. + * @param question_attempt $qa the attempt to iterate over. + */ + public function __construct(question_attempt $qa) { + $this->qa = $qa; + $this->rewind(); + } + + /** @return question_attempt_step */ + public function current() { + return $this->offsetGet($this->i); + } + /** @return integer */ + public function key() { + return $this->i; + } + public function next() { + ++$this->i; + } + public function rewind() { + $this->i = 0; + } + /** @return boolean */ + public function valid() { + return $this->offsetExists($this->i); + } + + /** @return boolean */ + public function offsetExists($i) { + return $i >= 0 && $i < $this->qa->get_num_steps(); + } + /** @return question_attempt_step */ + public function offsetGet($i) { + return $this->qa->get_step($i); + } + public function offsetSet($offset, $value) { + throw new Exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.'); + } + public function offsetUnset($offset) { + throw new Exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.'); + } +} + + +/** + * A variant of {@link question_attempt_step_iterator} that iterates through the + * steps in reverse order. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt_reverse_step_iterator extends question_attempt_step_iterator { + public function next() { + --$this->i; + } + + public function rewind() { + $this->i = $this->qa->get_num_steps() - 1; + } +} + + +/** + * Stores one step in a {@link question_attempt}. + * + * The most important attributes of a step are the state, which is one of the + * {@link question_state} constants, the fraction, which may be null, or a + * number bewteen the attempt's minfraction and 1.0, and the array of submitted + * data, about which more later. + * + * A step also tracks the time it was created, and the user responsible for + * creating it. + * + * The submitted data is basically just an array of name => value pairs, with + * certain conventions about the to divide the variables into four = two times two + * categories. + * + * Variables may either belong to the behaviour, in which case the + * name starts with a '-', or they may belong to the question type in which case + * they name does not start with a '-'. + * + * Second, variables may either be ones that came form the original request, in + * which case the name does not start with an _, or they are cached values that + * were created during processing, in which case the name does start with an _. + * + * That is, each name will start with one of '', '_'. '-' or '-_'. The remainder + * of the name should match the regex [a-z][a-z0-9]*. + * + * These variables can be accessed with {@link get_behaviour_var()} and {@link get_qt_var()}, + * - to be clear, ->get_behaviour_var('x') gets the variable with name '-x' - + * and values whose names start with '_' can be set using {@link set_behaviour_var()} + * and {@link set_qt_var()}. There are some other methods like {@link has_behaviour_var()} + * to check wether a varaible with a particular name is set, and {@link get_behaviour_data()} + * to get all the behaviour data as an associative array. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt_step { + /** @var integer if this attempts is stored in the question_attempts table, the id of that row. */ + private $id = null; + + /** @var question_state one of the {@link question_state} constants. The state after this step. */ + private $state; + + /** @var null|number the fraction (grade on a scale of minfraction .. 1.0) or null. */ + private $fraction = null; + + /** @var integer the timestamp when this step was created. */ + private $timecreated; + + /** @var integer the id of the user resonsible for creating this step. */ + private $userid; + + /** @var array name => value pairs. The submitted data. */ + private $data; + + /** + * You should not need to call this constructor in your own code. Steps are + * normally created by {@link question_attempt} methods like + * {@link question_attempt::process_action()}. + * @param array $data the submitted data that defines this step. + * @param integer $timestamp the time to record for the action. (If not given, use now.) + * @param integer $userid the user to attribute the aciton to. (If not given, use the current user.) + */ + public function __construct($data = array(), $timecreated = null, $userid = null) { + global $USER; + $this->state = question_state::$unprocessed; + $this->data = $data; + if (is_null($timecreated)) { + $this->timecreated = time(); + } else { + $this->timecreated = $timecreated; + } + if (is_null($userid)) { + $this->userid = $USER->id; + } else { + $this->userid = $userid; + } + } + + /** @return question_state The state after this step. */ + public function get_state() { + return $this->state; + } + + /** + * Set the state. Normally only called by behaviours. + * @param question_state $state one of the {@link question_state} constants. + */ + public function set_state($state) { + $this->state = $state; + } + + /** + * @return null|number the fraction (grade on a scale of minfraction .. 1.0) + * or null if this step has not been marked. + */ + public function get_fraction() { + return $this->fraction; + } + + /** + * Set the fraction. Normally only called by behaviours. + * @param null|number $fraction the fraction to set. + */ + public function set_fraction($fraction) { + $this->fraction = $fraction; + } + + /** @return integer the id of the user resonsible for creating this step. */ + public function get_user_id() { + return $this->userid; + } + + /** @return integer the timestamp when this step was created. */ + public function get_timecreated() { + return $this->timecreated; + } + + /** + * @param string $name the name of a question type variable to look for in the submitted data. + * @return boolean whether a variable with this name exists in the question type data. + */ + public function has_qt_var($name) { + return array_key_exists($name, $this->data); + } + + /** + * @param string $name the name of a question type variable to look for in the submitted data. + * @return string the requested variable, or null if the variable is not set. + */ + public function get_qt_var($name) { + if (!$this->has_qt_var($name)) { + return null; + } + return $this->data[$name]; + } + + /** + * Set a cached question type variable. + * @param string $name the name of the variable to set. Must match _[a-z][a-z0-9]*. + * @param string $value the value to set. + */ + public function set_qt_var($name, $value) { + if ($name[0] != '_') { + throw new Exception('Cannot set question type data ' . $name . ' on an attempt step. You can only set variables with names begining with _.'); + } + $this->data[$name] = $value; + } + + /** + * Get all the question type variables. + * @param array name => value pairs. + */ + public function get_qt_data() { + $result = array(); + foreach ($this->data as $name => $value) { + if ($name[0] != '-' && $name[0] != ':') { + $result[$name] = $value; + } + } + return $result; + } + + /** + * @param string $name the name of an behaviour variable to look for in the submitted data. + * @return boolean whether a variable with this name exists in the question type data. + */ + public function has_behaviour_var($name) { + return array_key_exists('-' . $name, $this->data); + } + + /** + * @param string $name the name of an behaviour variable to look for in the submitted data. + * @return string the requested variable, or null if the variable is not set. + */ + public function get_behaviour_var($name) { + if (!$this->has_behaviour_var($name)) { + return null; + } + return $this->data['-' . $name]; + } + + /** + * Set a cached behaviour variable. + * @param string $name the name of the variable to set. Must match _[a-z][a-z0-9]*. + * @param string $value the value to set. + */ + public function set_behaviour_var($name, $value) { + if ($name[0] != '_') { + throw new Exception('Cannot set question type data ' . $name . ' on an attempt step. You can only set variables with names begining with _.'); + } + return $this->data['-' . $name] = $value; + } + + /** + * Get all the behaviour variables. + * @param array name => value pairs. + */ + public function get_behaviour_data() { + $result = array(); + foreach ($this->data as $name => $value) { + if ($name[0] == '-') { + $result[substr($name, 1)] = $value; + } + } + return $result; + } + + /** + * Get all the submitted data, but not the cached data. behaviour + * variables have the - at the start of their name. This is only really + * intended for use by {@link question_attempt::regrade()}, it should not + * be considered part of the public API. + * @param array name => value pairs. + */ + public function get_submitted_data() { + $result = array(); + foreach ($this->data as $name => $value) { + if ($name[0] == '_' || ($name[0] == '-' && $name[1] == '_')) { + continue; + } + $result[$name] = $value; + } + return $result; + } + + /** + * Get all the data. behaviour variables have the ! at the start of + * their name. This is only intended for internal use, for example by + * {@link question_engine_data_mapper::insert_question_attempt_step()}, + * however, it can ocasionally be useful in test code. It should not be + * considered part of the public API of this class. + * @param array name => value pairs. + */ + public function get_all_data() { + return $this->data; + } + + /** + * Create a question_attempt_step from records loaded from the database. + * @param array $records Raw records loaded from the database. + * @param integer $stepid The id of the records to extract. + * @return question_attempt_step The newly constructed question_attempt_step. + */ + public static function load_from_records(&$records, $attemptstepid) { + $currentrec = current($records); + while ($currentrec->attemptstepid != $attemptstepid) { + $currentrec = next($records); + if (!$currentrec) { + throw new Exception("Question attempt step $attemptstepid not found in the database."); + } + } + + $record = $currentrec; + $data = array(); + while ($currentrec && $currentrec->attemptstepid == $attemptstepid) { + if ($currentrec->name) { + $data[$currentrec->name] = $currentrec->value; + } + $currentrec = next($records); + } + + $step = new question_attempt_step_read_only($data, $record->timecreated, $record->userid); + $step->state = question_state::get($record->state); + if (!is_null($record->fraction)) { + $step->fraction = $record->fraction + 0; + } + return $step; + } +} + + +/** + * A subclass with a bit of additional funcitonality, for pending steps. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt_pending_step extends question_attempt_step { + /** @var string . */ + protected $newresponsesummary = null; + + /** + * If as a result of processing this step, the response summary for the + * question attempt should changed, you should call this method to set the + * new summary. + * @param string $responsesummary the new response summary. + */ + public function set_new_response_summary($responsesummary) { + $this->newresponsesummary = $responsesummary; + } + + /** @return string the new response summary, if any. */ + public function get_new_response_summary() { + return $this->newresponsesummary; + } + + /** @return string whether this step changes the response summary. */ + public function response_summary_changed() { + return !is_null($this->newresponsesummary); + } +} + + +/** + * A subclass of {@link question_attempt_step} that cannot be modified. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt_step_read_only extends question_attempt_step { + public function set_state($state) { + throw new coding_exception('Cannot modify a question_attempt_step_read_only.'); + } + public function set_fraction($fraction) { + throw new coding_exception('Cannot modify a question_attempt_step_read_only.'); + } + public function set_qt_var($name, $value) { + throw new coding_exception('Cannot modify a question_attempt_step_read_only.'); + } + public function set_behaviour_var($name, $value) { + throw new coding_exception('Cannot modify a question_attempt_step_read_only.'); + } +} + + +/** + * A null {@link question_attempt_step} returned from + * {@link question_attempt::get_last_step()} etc. when a an attempt has just been + * created and there is no acutal step. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_null_step { + public function get_state() { + return question_state::$notstarted; + } + + public function set_state($state) { + throw new Exception('This question has not been started.'); + } + + public function get_fraction() { + return null; + } +} + + +/** + * Interface for things that want to be notified of signficant changes to a + * {@link question_usage_by_activity}. + * + * A question behaviour controls the flow of actions a student can + * take as they work through a question, and later, as a teacher manually grades it. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface question_usage_observer { + /** Called when a field of the question_usage_by_activity is changed. */ + public function notify_modified(); + + /** + * Called when the fields of a question attempt in this usage are modified. + * @param question_attempt $qa the newly added question attempt. + */ + 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. + */ + public function notify_attempt_added(question_attempt $qa); + + /** + * Called we want to delete the old step records for an attempt, prior to + * inserting newones. This is used by regrading. + * @param question_attempt $qa the question attempt to delete the steps for. + */ + public function notify_delete_attempt_steps(question_attempt $qa); + + /** + * Called when a new step is added to a question attempt in this usage. + * @param $step the new step. + * @param $qa the usage it is being added to. + * @param $seq the sequence number of the new step. + */ + public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq); +} + + +/** + * Null implmentation of the {@link question_usage_watcher} interface. + * Does nothing. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_usage_null_observer implements question_usage_observer { + public function notify_modified() { + } + public function notify_attempt_modified(question_attempt $qa) { + } + public function notify_attempt_added(question_attempt $qa) { + } + public function notify_delete_attempt_steps(question_attempt $qa) { + } + public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) { + } +} + + +/** + * Useful functions for writing question types and behaviours. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_utils { + /** + * Tests to see whether two arrays have the same keys, with the same values + * (as compared by ===) for each key. However, the order of the arrays does + * not have to be the same. + * @param array $array1 the first array. + * @param array $array2 the second array. + * @return boolean whether the two arrays have the same keys with the same + * corresponding values. + */ + public static function arrays_have_same_keys_and_values(array $array1, array $array2) { + if (count($array1) != count($array2)) { + return false; + } + foreach ($array1 as $key => $value1) { + if (!array_key_exists($key, $array2)) { + return false; + } + if (((string) $value1) !== ((string) $array2[$key])) { + return false; + } + } + return true; + } + + /** + * Tests to see whether two arrays have the same value at a particular key. + * This method will return true if: + * 1. Neither array contains the key; or + * 2. Both arrays contain the key, and the corresponding values compare + * identical when cast to strings and compared with ===. + * @param array $array1 the first array. + * @param array $array2 the second array. + * @param string $key an array key. + * @return boolean whether the two arrays have the same value (or lack of + * one) for a given key. + */ + public static function arrays_same_at_key(array $array1, array $array2, $key) { + if (array_key_exists($key, $array1) && array_key_exists($key, $array2)) { + return ((string) $array1[$key]) === ((string) $array2[$key]); + } + if (!array_key_exists($key, $array1) && !array_key_exists($key, $array2)) { + return true; + } + return false; + } + + /** + * Tests to see whether two arrays have the same value at a particular key. + * Missing values are replaced by '', and then the values are cast to + * strings and compared with ===. + * @param array $array1 the first array. + * @param array $array2 the second array. + * @param string $key an array key. + * @return boolean whether the two arrays have the same value (or lack of + * one) for a given key. + */ + public static function arrays_same_at_key_missing_is_blank( + array $array1, array $array2, $key) { + if (array_key_exists($key, $array1)) { + $value1 = $array1[$key]; + } else { + $value1 = ''; + } + if (array_key_exists($key, $array2)) { + $value2 = $array2[$key]; + } else { + $value2 = ''; + } + return ((string) $value1) === ((string) $value2); + } + + /** + * Tests to see whether two arrays have the same value at a particular key. + * Missing values are replaced by 0, and then the values are cast to + * integers and compared with ===. + * @param array $array1 the first array. + * @param array $array2 the second array. + * @param string $key an array key. + * @return boolean whether the two arrays have the same value (or lack of + * one) for a given key. + */ + public static function arrays_same_at_key_integer( + array $array1, array $array2, $key) { + if (array_key_exists($key, $array1)) { + $value1 = $array1[$key]; + } else { + $value1 = 0; + } + if (array_key_exists($key, $array2)) { + $value2 = $array2[$key]; + } else { + $value2 = 0; + } + return ((integer) $value1) === ((integer) $value2); + } + + private static $units = array('', 'i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii', 'viii', 'ix'); + private static $tens = array('', 'x', 'xx', 'xxx', 'xl', 'l', 'lx', 'lxx', 'lxxx', 'xc'); + private static $hundreds = array('', 'c', 'cc', 'ccc', 'cd', 'd', 'dc', 'dcc', 'dccc', 'cm'); + private static $thousands = array('', 'm', 'mm', 'mmm'); + + /** + * Convert an integer to roman numerals. + * @param integer $number an integer between 1 and 3999 inclusive. Anything else will throw an exception. + * @return string the number converted to lower case roman numerals. + */ + public static function int_to_roman($number) { + if (!is_integer($number) || $number < 1 || $number > 3999) { + throw new coding_exception('Only integers between 0 and 3999 can be ' . + 'converted to roman numerals.', $number); + } + + return self::$thousands[$number / 1000 % 10] . self::$hundreds[$number / 100 % 10] . + self::$tens[$number / 10 % 10] . self::$units[$number % 10]; + } +} \ No newline at end of file diff --git a/question/engine/renderer.php b/question/engine/renderer.php new file mode 100644 index 00000000000..2f45bcbe2d4 --- /dev/null +++ b/question/engine/renderer.php @@ -0,0 +1,362 @@ +. + + +/** + * Renderers for outputting parts of the question engine. + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * This renderer controls the overall output of questions. It works with a + * {@link qbehaviour_renderer} and a {@link qtype_renderer} to output the + * type-specific bits. The main entry point is the {@link question()} method. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_question_renderer extends renderer_base { + + /** + * Generate the display of a question in a particular state, and with certain + * display options. Normally you do not call this method directly. Intsead + * you call {@link question_usage_by_activity::render_question()} which will + * call this method with appropriate arguments. + * + * @param question_attempt $qa the question attempt to display. + * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour + * specific parts. + * @param qtype_renderer $qtoutput the renderer to output the question type + * specific parts. + * @param question_display_options $options controls what should and should not be displayed. + * @param string|null $number The question number to display. 'i' is a special + * value that gets displayed as Information. Null means no number is displayed. + * @return string HTML representation of the question. + */ + public function question(question_attempt $qa, qbehaviour_renderer $behaviouroutput, + qtype_renderer $qtoutput, question_display_options $options, $number) { + + $output = ''; + $output .= html_writer::start_tag('div', array( + 'id' => 'q' . $qa->get_slot(), + 'class' => 'que ' . $qa->get_question()->qtype->name() . ' ' . + $qa->get_behaviour_name(), + )); + + $output .= html_writer::tag('div', + $this->info($qa, $behaviouroutput, $qtoutput, $options, $number), + array('class' => 'info')); + + $output .= html_writer::start_tag('div', array('class' => 'content')); + + $output .= html_writer::tag('div', + $this->add_part_heading(get_string('questiontext', 'question'), + $this->formulation($qa, $behaviouroutput, $qtoutput, $options)), + array('class' => 'formulation')); + $output .= html_writer::nonempty_tag('div', + $this->add_part_heading(get_string('feedback', 'question'), + $this->outcome($qa, $behaviouroutput, $qtoutput, $options)), + array('class' => 'outcome')); + $output .= html_writer::nonempty_tag('div', + $this->add_part_heading(get_string('comments', 'question'), + $behaviouroutput->manual_comment($qa, $options)), array('class' => 'comment')); + $output .= html_writer::nonempty_tag('div', + $this->response_history($qa, $behaviouroutput, $qtoutput, $options), + array('class' => 'history')); + + $output .= html_writer::end_tag('div'); + $output .= html_writer::end_tag('div'); + return $output; + } + + /** + * Generate the information bit of the question display that contains the + * metadata like the question number, current state, and mark. + * @param question_attempt $qa the question attempt to display. + * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour + * specific parts. + * @param qtype_renderer $qtoutput the renderer to output the question type + * specific parts. + * @param question_display_options $options controls what should and should not be displayed. + * @param string|null $number The question number to display. 'i' is a special + * value that gets displayed as Information. Null means no number is displayed. + * @return HTML fragment. + */ + protected function info(question_attempt $qa, qbehaviour_renderer $behaviouroutput, + qtype_renderer $qtoutput, question_display_options $options, $number) { + $output = ''; + $output .= $this->number($number); + $output .= $this->status($qa, $behaviouroutput, $options); + $output .= $this->mark_summary($qa, $options); + $output .= $this->question_flag($qa, $options->flags); + return $output; + } + + /** + * Generate the display of the question number. + * @param string|null $number The question number to display. 'i' is a special + * value that gets displayed as Information. Null means no number is displayed. + * @return HTML fragment. + */ + protected function number($number) { + $numbertext = ''; + if (is_numeric($number)) { + $numbertext = get_string('questionx', 'question', + html_writer::tag('span', $number, array('class' => 'qno'))); + } else if ($number == 'i') { + $numbertext = get_string('information', 'question'); + } + if (!$numbertext) { + return ''; + } + return html_writer::tag('h2', $numbertext, array('class' => 'no')); + } + + /** + * Add an invisible heading like 'question text', 'feebdack' at the top of + * a section's contents, but only if the section has some content. + * @param string $heading the heading to add. + * @param string $content the content of the section. + * @return string HTML fragment with the heading added. + */ + protected function add_part_heading($heading, $content) { + if ($content) { + $content = html_writer::tag('h3', $heading, array('class' => 'accesshide')) . $content; + } + return $content; + } + + /** + * Generate the display of the status line that gives the current state of + * the question. + * @param question_attempt $qa the question attempt to display. + * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour + * specific parts. + * @param question_display_options $options controls what should and should not be displayed. + * @return HTML fragment. + */ + protected function status(question_attempt $qa, qbehaviour_renderer $behaviouroutput, question_display_options $options) { + return html_writer::tag('div', $qa->get_state_string($options->correctness), + array('class' => 'state')); + } + + /** + * Generate the display of the marks for this question. + * @param question_attempt $qa the question attempt to display. + * @param question_display_options $options controls what should and should not be displayed. + * @return HTML fragment. + */ + protected function mark_summary(question_attempt $qa, question_display_options $options) { + if (!$options->marks) { + return ''; + } + + if ($qa->get_max_mark() == 0) { + $summary = get_string('notgraded', 'question'); + + } else if ($options->marks == question_display_options::MAX_ONLY || + is_null($qa->get_fraction())) { + $summary = get_string('markedoutofmax', 'question', $qa->format_max_mark($options->markdp)); + + } else { + $a = new stdClass; + $a->mark = $qa->format_mark($options->markdp); + $a->max = $qa->format_max_mark($options->markdp); + $summary = get_string('markoutofmax', 'question', $a); + } + + return html_writer::tag('div', $summary, array('class' => 'grade')); + } + + /** + * Render the question flag, assuming $flagsoption allows it. + * + * @param question_attempt $qa the question attempt to display. + * @param integer $flagsoption the option that says whether flags should be displayed. + */ + protected function question_flag(question_attempt $qa, $flagsoption) { + global $CFG; + switch ($flagsoption) { + case question_display_options::VISIBLE: + $flagcontent = $this->get_flag_html($qa->is_flagged()); + break; + case question_display_options::EDITABLE: + $id = $qa->get_flag_field_name(); + if ($qa->is_flagged()) { + $checked = 'checked="checked" '; + } else { + $checked = ''; + } + $postdata = question_flags::get_postdata($qa); + // The checkbox id must be different from any element name, becuase + // of a stupid IE bug: http://www.456bereastreet.com/archive/200802/beware_of_id_and_name_attribute_mixups_when_using_getelementbyid_in_internet_explorer/ + $flagcontent = '' . + '' . + '' . "\n" . + print_js_call('question_flag_changer.init_flag', array($id, $postdata, $qa->get_slot()), true); + break; + default: + $flagcontent = ''; + } + if ($flagcontent) { + return '' . $description; + if (!$quizattemptid) { + $quizattemptid = $this->attemptid; + } + if ($quizattemptid) { + $message .= ' (Review this attempt)'; + } + $message .= "
\n"; + fwrite($this->handle, $message); + } + + public function __destruct() { + fwrite($this->handle, ''); + fclose($this->handle); + } +} + + +/** + * This class manages upgrading all the question attempts from the old database + * structure to the new question engine. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_engine_attempt_upgrader { + /** @var question_engine_upgrade_question_loader */ + protected $questionloader; + protected $logger; + + protected function print_progress($done, $outof, $quizid) { + gc_collect_cycles(); + print_progress($done, $outof); + } + + protected function prevent_timeout() { + set_time_limit(300); + } + + protected function table_exists($tablename) { + global $db; + $metatables = $db->MetaTables(); + $metatables = array_flip($metatables); + $metatables = array_change_key_case($metatables, CASE_LOWER); + return array_key_exists($tablename, $metatables); + } + + protected function get_quiz_ids() { + global $CFG; + if ($this->table_exists('vl_v_crs_version_pres')) { + $quizmoduleid = get_field('modules', 'id', 'name', 'quiz'); + + return get_records_sql_menu(" + SELECT quiz.id,1 + + FROM {$CFG->prefix}quiz quiz + JOIN {$CFG->prefix}course_modules cm ON cm.module = {$quizmoduleid} + AND cm.instance = quiz.id + JOIN {$CFG->prefix}course c ON quiz.course = c.id + LEFT JOIN vl_v_crs_version_pres v ON v.vle_course_short_name = c.shortname + AND v.vle_control_course = 'Y' + + WHERE cm.idnumber <> '' + OR v.vle_student_close_date IS NULL + OR (v.vle_student_close_date > '2010-12-01' AND c.shortname <> 'MU123-10B') + "); + + } else { + return get_records_menu('quiz', '', '', 'id', 'id,1'); + } + } + + public function convert_all_quiz_attempts() { + $quizids = $this->get_quiz_ids(); + if (!$quizids) { + return true; + } + + $done = 0; + $outof = count($quizids); + $this->logger = new question_engine_assumption_logger(); + + $success = true; + foreach ($quizids as $quizid => $notused) { + $this->print_progress($done, $outof, $quizid); + + $quiz = get_record('quiz', 'id', $quizid); + $success = $success && $this->update_all_attemtps_at_quiz($quiz); + if (!$success) { + return false; + } + + $done += 1; + } + + $this->print_progress($outof, $outof, 'All done!'); + $this->logger = null; + return $success; + } + + public function get_attemtps_where($quizid) { + return "quiz = {$quizid} AND preview = 0 AND needsupgradetonewqe = 1"; + } + + public function update_all_attemtps_at_quiz($quiz) { + global $CFG; + + // Wipe question loader cache. + $this->questionloader = new question_engine_upgrade_question_loader($this->logger); + + begin_sql(); + + $where = $this->get_attemtps_where($quiz->id); + + $quizattemptsrs = get_recordset_select('quiz_attempts', $where, 'uniqueid'); + $questionsessionsrs = get_recordset_sql(" + SELECT * + FROM {$CFG->prefix}question_sessions + WHERE attemptid IN ( + SELECT uniqueid FROM {$CFG->prefix}quiz_attempts WHERE $where) + ORDER BY attemptid, questionid + "); + + $questionsstatesrs = get_recordset_sql(" + SELECT * + FROM {$CFG->prefix}question_states + WHERE attempt IN ( + SELECT uniqueid FROM {$CFG->prefix}quiz_attempts WHERE $where) + ORDER BY attempt, question, seq_number, id + "); + + $success = $quizattemptsrs && $questionsessionsrs && $questionsstatesrs; + while ($success && ($attempt = rs_fetch_next_record($quizattemptsrs))) { + $success = $success && $this->convert_quiz_attempt( + $quiz, $attempt, $questionsessionsrs, $questionsstatesrs); + } + + rs_close($quizattemptsrs); + rs_close($questionsessionsrs); + rs_close($questionsstatesrs); + + if ($success) { + commit_sql(); + } else { + rollback_sql(); + } + + return $success; + } + + protected function convert_quiz_attempt($quiz, $attempt, $questionsessionsrs, $questionsstatesrs) { + $qas = array(); + $this->logger->set_current_attempt_id($attempt->id); + while ($qsession = $this->get_next_question_session($attempt, $questionsessionsrs)) { + $question = $this->load_question($qsession->questionid, $quiz->id); + $qstates = $this->get_question_states($attempt, $question, $questionsstatesrs); + try { + $qas[$qsession->questionid] = $this->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); + } catch (Exception $e) { + notify($e->getMessage()); + } + } + $this->logger->set_current_attempt_id(null); + + if (empty($qas)) { + $this->logger->log_assumption("All the question attempts for + attempt {$attempt->id} at quiz {$attempt->quiz} were missing. + Deleting this attempt", $attempt->id); + // Somehow, all the question attempt data for this quiz attempt + // was lost. (This seems to have happened on labspace.) + // Delete the corresponding quiz attempt. + return $this->delete_quiz_attempt($attempt->uniqueid); + } + + $questionorder = array(); + foreach (explode(',', $quiz->questions) as $questionid) { + if ($questionid == 0) { + continue; + } + if (!array_key_exists($questionid, $qas)) { + $this->logger->log_assumption("Supplying minimal open state for + question {$questionid} in attempt {$attempt->id} at quiz + {$attempt->quiz}, since the session was missing.", $attempt->id); + try { + $qas[$questionid] = $this->supply_missing_question_attempt( + $quiz, $attempt, $question); + } catch (Exception $e) { + notify($e->getMessage()); + } + } + } + + return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions); + } + + protected function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) { + $missing = array(); + $success = true; + + $layout = explode(',', $attempt->layout); + $questionkeys = array_combine(array_values($layout), array_keys($layout)); + + $success = $success && $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour); + if (!$success) { + return false; + } + + $i = 0; + foreach (explode(',', $quizlayout) as $questionid) { + if ($questionid == 0) { + continue; + } + $i++; + + if (!array_key_exists($questionid, $qas)) { + $missing[] = $questionid; + continue; + } + + $qa = $qas[$questionid]; + $qa->questionusageid = $attempt->uniqueid; + $qa->slot = $i; + $success = $success && $this->insert_record('question_attempts', $qa); + if (!$success) { + return false; + } + $layout[$questionkeys[$questionid]] = $qa->slot; + + foreach ($qa->steps as $step) { + $step->questionattemptid = $qa->id; + $success = $success && $this->insert_record('question_attempt_steps', $step); + if (!$success) { + return false; + } + + foreach ($step->data as $name => $value) { + $datum = new stdClass(); + $datum->attemptstepid = $step->id; + $datum->name = $name; + $datum->value = $value; + $success = $success && $this->insert_record( + 'question_attempt_step_data', $datum, false); + } + } + } + + $success = $success && $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout)); + + if ($missing) { + notify("Question sessions for questions " . + implode(', ', $missing) . + " were missing when upgrading question usage {$attempt->uniqueid}."); + } + + return $success; + } + + protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) { + return set_field('question_usages', 'preferredbehaviour', $preferredbehaviour, 'id', $qubaid); + } + + protected function set_quiz_attempt_layout($qubaid, $layout) { + $success = true; + $success = $success && set_field('quiz_attempts', 'layout', $layout, 'uniqueid', $qubaid); + $success = $success && set_field('quiz_attempts', 'needsupgradetonewqe', 0, 'uniqueid', $qubaid); + return $success; + } + + protected function delete_quiz_attempt($qubaid) { + $success = true; + $success = $success && delete_records('quiz_attempts', 'uniqueid', $qubaid); + $success = $success && delete_records('question_attempts', 'id', $qubaid); + return $success; + } + + protected function escape_fields($record) { + foreach (get_object_vars($record) as $field => $value) { + if (is_string($value)) { + $record->$field = addslashes($value); + } + } + } + protected function insert_record($table, $record, $saveid = true) { + $this->escape_fields($record); + $newid = insert_record($table, $record, $saveid); + if ($saveid) { + $record->id = $newid; + } + return $newid; + } + + public function load_question($questionid, $quizid = null) { + return $this->questionloader->get_question($questionid, $quizid); + } + + public function get_next_question_session($attempt, $questionsessionsrs) { + $qsession = rs_fetch_record($questionsessionsrs); + + if (!$qsession || $qsession->attemptid != $attempt->uniqueid) { + // No more question sessions belonging to this attempt. + return false; + } + + // Session found, move the pointer in the RS and return the record. + rs_next_record($questionsessionsrs); + return $qsession; + } + + public function get_question_states($attempt, $question, $questionsstatesrs) { + $qstates = array(); + + while ($state = rs_fetch_record($questionsstatesrs)) { + if (!$state || $state->attempt != $attempt->uniqueid || + $state->question != $question->id) { + // We have found all the states for this attempt. Stop. + break; + } + + // Add the new state to the array, and advance. + $qstates[$state->seq_number] = $state; + rs_next_record($questionsstatesrs); + } + + return $qstates; + } + + public function format_var($name, $var) { + $out = var_export($var, true); + $out = str_replace('<', '<', $out); + $out = str_replace('ADOFetchObj::__set_state(array(', '(object) array(', $out); + $out = str_replace('stdClass::__set_state(array(', '(object) array(', $out); + $out = str_replace('array (', 'array(', $out); + $out = preg_replace('/=> \n\s*/', '=> ', $out); + $out = str_replace(')),', '),', $out); + $out = str_replace('))', ')', $out); + $out = preg_replace('/\n (?! )/', "\n ", $out); + $out = preg_replace('/\n (?! )/', "\n ", $out); + $out = preg_replace('/\n (?! )/', "\n ", $out); + $out = preg_replace('/\n (?! )/', "\n ", $out); + $out = preg_replace('/\n (?! )/', "\n ", $out); + $out = preg_replace('/\n (?! )/', "\n ", $out); + $out = preg_replace('/\n (?! )/', "\n ", $out); + $out = preg_replace('/\n(?! )/', "\n ", $out); + return " $name = $out;\n"; + } + + public function display_convert_attempt_input($quiz, $attempt, $question, $qsession, $qstates) { + echo $this->format_var('$quiz', $quiz); + echo $this->format_var('$attempt', $attempt); + echo $this->format_var('$question', $question); + echo $this->format_var('$qsession', $qsession); + echo $this->format_var('$qstates', $qstates); + } + + protected function get_converter_class_name($question, $quiz, $qsessionid) { + if (in_array($question->qtype, array('calculated', 'multianswer', 'randomsamatch'))) { + throw new coding_exception("Question session {$qsessionid} uses unsupported question type {$question->qtype}."); + } else if ($question->qtype == 'essay') { + return 'qbehaviour_manualgraded_converter'; + } else if ($question->qtype == 'description') { + return 'qbehaviour_informationitem_converter'; + } else if ($question->qtype == 'opaque') { + return 'qbehaviour_opaque_converter'; + } else if ($quiz->preferredbehaviour == 'interactive') { + return 'qbehaviour_interactive_converter'; + } else if ($quiz->preferredbehaviour == 'deferredfeedback') { + return 'qbehaviour_deferredfeedback_converter'; + } else { + throw new coding_exception("Question session {$qsessionid} has an unexpected preferred behaviour {$quiz->preferredbehaviour}."); + } + } + + public function supply_missing_question_attempt($quiz, $attempt, $question) { + if ($question->qtype == 'random') { + throw new coding_exception("Cannot supply a missing qsession for question {$question->id} in attempt {$attempt->id}."); + } + + $converterclass = $this->get_converter_class_name($question, $quiz, 'missing'); + + $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, null, null, $this->logger); + $qa = $qbehaviourupdater->supply_missing_qa(); + $qbehaviourupdater->discard(); + return $qa; + } + + public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) { + $this->prevent_timeout(); + + if ($question->qtype == 'random') { + list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark); + $qsession->questionid = $question->id; + } + + $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id); + + $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession, $qstates, $this->logger); + $qa = $qbehaviourupdater->get_converted_qa(); + $qbehaviourupdater->discard(); + return $qa; + } + + protected function decode_random_attempt($qstates, $maxmark) { + $realquestionid = null; + foreach ($qstates as $i => $state) { + if (strpos($state->answer, '-') < 6) { + // Broken state, skip it. + $this->logger->log_assumption("Had to skip brokes state {$state->id} + for question {$state->question}."); + unset($qstates[$i]); + continue; + } + list($randombit, $realanswer) = explode('-', $state->answer, 2); + $newquestionid = substr($randombit, 6); + if ($realquestionid && $realquestionid != $newquestionid) { + throw new coding_exception("Question session {$this->qsession->id} for random question points to two different real questions {$realquestionid} and {$newquestionid}."); + } + $qstates[$i]->answer = $realanswer; + } + + if (empty($newquestionid)) { + // This attempt only had broken states. Set a fake $newquestionid to + // prevent a null DB error later. + $newquestionid = 0; + } + + $newquestion = $this->load_question($newquestionid); + $newquestion->maxmark = $maxmark; + return array($newquestion, $qstates); + } +} + +/** + * This class deals with loading (and caching) question definitions during the + * question engine upgrade. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_engine_upgrade_question_loader { + private $cache = array(); + + public function __construct($logger) { + $this->logger = $logger; + } + + protected function load_question($questionid, $quizid) { + global $CFG, $QTYPES; + + if ($quizid) { + $question = get_record_sql(" + SELECT q.*, qqi.grade AS maxmark + FROM {$CFG->prefix}question q + JOIN {$CFG->prefix}quiz_question_instances qqi ON qqi.question = q.id + WHERE q.id = $questionid AND qqi.quiz = $quizid"); + } else { + $question = get_record('question', 'id', $questionid); + } + + if (!$question) { + return null; + } + + if (empty($question->defaultmark)) { + if (!empty($question->defaultgrade)) { + $question->defaultmark = $question->defaultgrade; + } else { + $question->defaultmark = 0; + } + unset($question->defaultgrade); + } + + if (!array_key_exists($question->qtype, $QTYPES)) { + $this->logger->log_assumption("Dealing with question id {$question->id} + that is of an unknown type {$question->qtype}."); + $question->qtype = 'missingtype'; + $question->questiontext = '' . get_string('warningmissingtype', 'quiz') . '
' . $question->questiontext; + } + + $QTYPES[$question->qtype]->get_question_options($question); + + return $question; + } + + public function get_question($questionid, $quizid) { + if (isset($this->cache[$questionid])) { + return $this->cache[$questionid]; + } + + $question = $this->load_question($questionid, $quizid); + + if (!$question) { + $this->logger->log_assumption("Dealing with question id {$questionid} + that was missing from the database."); + $question = new stdClass(); + $question->id = $questionid; + $question->qtype = 'deleted'; + $question->maxmark = 1; // Guess, but that is all we can do. + $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype'); + } + + $this->cache[$questionid] = $question; + return $this->cache[$questionid]; + } +} + + +abstract class qbehaviour_converter { + protected $qtypeupdater; + protected $logger; + + protected $qa; + + protected $quiz; + protected $attempt; + protected $question; + protected $qsession; + protected $qstates; + + protected $sequencenumber; + protected $finishstate; + protected $alreadystarted; + + public function __construct($quiz, $attempt, $question, $qsession, $qstates, $logger) { + $this->quiz = $quiz; + $this->attempt = $attempt; + $this->question = $question; + $this->qsession = $qsession; + $this->qstates = $qstates; + $this->logger = $logger; + } + + public function discard() { + // Help the garbage collector, which seems to be struggling. + $this->quiz = null; + $this->attempt = null; + $this->question = null; + $this->qsession = null; + $this->qstates = null; + $this->qa = null; + $this->qtypeupdater->discard(); + $this->qtypeupdater = null; + $this->logger = null; + } + + protected abstract function behaviour_name(); + + public function get_converted_qa() { + $this->initialise_qa(); + $this->convert_steps(); + return $this->qa; + } + + protected function create_missing_first_step() { + $step = new stdClass(); + $step->state = 'todo'; + $step->data = array(); + $step->fraction = null; + $step->timecreated = $this->attempt->timestart; + $step->userid = $this->attempt->userid; + $this->qtypeupdater->supply_missing_first_step_data($step->data); + return $step; + } + + public function supply_missing_qa() { + $this->initialise_qa(); + $this->qa->timemodified = $this->attempt->timestart; + $this->sequencenumber = 0; + $this->add_step($this->create_missing_first_step()); + return $this->qa; + } + + protected function initialise_qa() { + $this->qtypeupdater = $this->make_qtype_updater(); + + $qa = new stdClass(); + $qa->questionid = $this->question->id; + $qa->behaviour = $this->behaviour_name(); + $qa->maxmark = $this->question->maxmark; + $qa->minfraction = 0; + $qa->flagged = 0; + $qa->questionsummary = $this->qtypeupdater->question_summary($this->question); + $qa->rightanswer = $this->qtypeupdater->right_answer($this->question); + $qa->responsesummary = ''; + $qa->timemodified = 0; + $qa->steps = array(); + + $this->qa = $qa; + } + + protected function convert_steps() { + $this->finishstate = null; + $this->startstate = null; + $this->sequencenumber = 0; + foreach ($this->qstates as $state) { + $this->process_state($state); + } + $this->finish_up(); + } + + protected function process_state($state) { + $step = $this->make_step($state); + $method = 'process' . $state->event; + $this->$method($step, $state); + } + + protected function finish_up() { + } + + protected function add_step($step) { + $step->sequencenumber = $this->sequencenumber; + $this->qa->steps[] = $step; + $this->sequencenumber++; + } + + protected function discard_last_state() { + array_pop($this->qa->steps); + $this->sequencenumber--; + } + + protected function unexpected_event($state) { + throw new coding_exception("Unexpected event {$state->event} in state {$state->id} in question session {$this->qsession->id}."); + } + + protected function process0($step, $state) { + if ($this->startstate) { + if ($state->answer == reset($this->qstates)->answer) { + return; + } else if ($this->quiz->attemptonlast && $this->sequencenumber == 1) { + // There was a bug in attemptonlast in the past, which meant that + // it created two inconsistent open states, with the second taking + // priority. Simulate that be discarding the first open state, then + // continuing. + $this->logger->log_assumption("Ignoring bogus state in attempt at question {$state->question}"); + $this->sequencenumber = 0; + $this->qa->steps = array(); + } else if ($state->answer == '') { + $this->logger->log_assumption("Ignoring second start state with blank answer in attempt at question {$state->question}"); + return; + } else { + throw new coding_exception("Two inconsistent open states for question session {$this->qsession->id}."); + } + } + $step->state = 'todo'; + $this->startstate = $state; + $this->add_step($step); + } + + protected function process1($step, $state) { + $this->unexpected_event($state); + } + + protected function process2($step, $state) { + if ($this->qtypeupdater->was_answered($state)) { + $step->state = 'complete'; + } else { + $step->state = 'todo'; + } + $this->add_step($step); + } + + protected function process3($step, $state) { + return $this->process6($step, $state); + } + + protected function process4($step, $state) { + $this->unexpected_event($state); + } + + protected function process5($step, $state) { + $this->unexpected_event($state); + } + + protected abstract function process6($step, $state); + protected abstract function process7($step, $state); + + protected function process8($step, $state) { + return $this->process6($step, $state); + } + + protected function process9($step, $state) { + if (!$this->finishstate) { + $submitstate = clone($state); + $submitstate->event = 8; + $submitstate->grade = 0; + $this->process_state($submitstate); + } + + $step->data['-comment'] = addslashes($this->qsession->manualcomment); + if ($this->question->maxmark > 0) { + $step->fraction = $state->grade / $this->question->maxmark; + $step->state = $this->manual_graded_state_for_fraction($step->fraction); + $step->data['-mark'] = $state->grade; + $step->data['-maxmark'] = $this->question->maxmark; + } else { + $step->state = 'manfinished'; + } + unset($step->data['answer']); + $step->userid = null; + $this->add_step($step); + } + + protected function process10($step, $state) { + $this->unexpected_event($state); + } + + /** + * @param stdClass $question a question definition + * @return qtype_updater + */ + protected function make_qtype_updater() { + $class = 'qtype_' . $this->question->qtype . '_updater'; + return new $class($this, $this->question, $this->logger); + } + + public function to_text($html) { + return trim(html_to_text($html, 0, false)); + } + + protected function graded_state_for_fraction($fraction) { + if ($fraction < 0.000001) { + return 'gradedwrong'; + } else if ($fraction > 0.999999) { + return 'gradedright'; + } else { + return 'gradedpartial'; + } + } + + protected function manual_graded_state_for_fraction($fraction) { + if ($fraction < 0.000001) { + return 'mangrwrong'; + } else if ($fraction > 0.999999) { + return 'mangrright'; + } else { + return 'mangrpartial'; + } + } + + protected function make_step($state){ + $step = new stdClass(); + $step->data = array(); + + if ($state->event == 0 || $this->sequencenumber == 0) { + $this->qtypeupdater->set_first_step_data_elements($state, $step->data); + } else { + $this->qtypeupdater->set_data_elements_for_step($state, $step->data); + } + + $step->fraction = null; + $step->timecreated = $state->timestamp; + $step->userid = $this->attempt->userid; + + $summary = $this->qtypeupdater->response_summary($state); + if (!is_null($summary)) { + $this->qa->responsesummary = $summary; + } + $this->qa->timemodified = max($this->qa->timemodified, $state->timestamp); + + return $step; + } +} + + +class qbehaviour_informationitem_converter extends qbehaviour_converter { + protected function behaviour_name() { + return 'informationitem'; + } + + protected function process0($step, $state) { + if ($this->startstate) { + return; + } + $step->state = 'todo'; + $this->startstate = $state; + $this->add_step($step); + } + + protected function process2($step, $state) { + $this->unexpected_event($state); + } + + protected function process3($step, $state) { + $this->unexpected_event($state); + } + + protected function process6($step, $state) { + if ($this->finishstate) { + return; + } + + $step->state = 'finished'; + $step->data['-finish'] = '1'; + $this->finishstate = $state; + $this->add_step($step); + } + + protected function process7($step, $state) { + return $this->process6($step, $state); + } + + protected function process8($step, $state) { + return $this->process6($step, $state); + } +} + + +class qbehaviour_opaque_converter extends qbehaviour_converter { + protected function behaviour_name() { + return 'opaque'; + } + + protected function create_missing_first_step() { + global $CFG; + $step = parent::create_missing_first_step(); + $step->data['-_preferredbehaviour'] = $this->quiz->preferredbehaviour; + $step->data['-_language'] = $CFG->lang; + $step->data['-_userid'] = $step->userid; + $step->data['-_statestring'] = 'You have [N] attempts.'; + return $step; + } + + protected function process0($step, $state) { + global $CFG; + $ok = parent::process0($step, $state); + $step->data['-_preferredbehaviour'] = $this->quiz->preferredbehaviour; + $step->data['-_language'] = $CFG->lang; + $step->data['-_userid'] = $step->userid; + $step->data['-_statestring'] = 'You have [N] attempts.'; + return $ok; + } + + protected function process2($step, $state) { + $step->state = 'todo'; + $this->add_step($step); + } + + protected function process3($step, $state) { + return $this->process2($step, $state); + } + + protected function process6($step, $state) { + if ($this->finishstate) { + throw new coding_exception("Two finish states found for opaque question session {$this->qsession->id}."); + } + + if (array_key_exists('-_actionSummary', $step->data) && + $step->data['-_actionSummary'] == '[Not completed]') { + $this->finish_up(); + return; + } + + if ($this->question->maxmark > 0) { + $step->fraction = $state->grade / $this->question->maxmark; + $step->state = $this->graded_state_for_fraction($step->fraction); + } else { + $step->state = 'finished'; + } + $this->finishstate = $state; + $this->add_step($step); + } + + protected function process7($step, $state) { + $this->unexpected_event($state); + } + + protected function process8($step, $state) { + $this->process6($step, $state); + } + + protected function finish_up() { + if ($this->finishstate || !$this->attempt->timefinish) { + return; + } + + $state = end($this->qstates); + $step = $this->make_step($state); + $step->data = array('-finish' => 1); + $step->state = 'gaveup'; + $this->qa->responsesummary = '[Not completed]'; + $this->finishstate = $state; + $this->add_step($step); + } +} + + +class qbehaviour_manualgraded_converter extends qbehaviour_converter { + protected function behaviour_name() { + return 'manualgraded'; + } + + protected function process6($step, $state) { + $step->state = 'needsgrading'; + if (!$this->finishstate) { + $step->data['-finish'] = '1'; + $this->finishstate = $state; + } + $this->add_step($step); + } + + protected function process7($step, $state) { + return $this->process6($step, $state); + } +} + + +class qbehaviour_interactive_converter extends qbehaviour_converter { + protected $triesleft; + + protected function behaviour_name() { + return 'interactive'; + } + + protected function finish_up() { + if ($this->triesleft == 0 || !$this->attempt->timefinish) { + return; + } + + $state = end($this->qstates); + $step = $this->make_step($state); + $step->data['-finish'] = 1; + + if ($this->question->maxmark > 0) { + $step->fraction = $state->grade / $this->question->maxmark; + $step->state = $this->graded_state_for_fraction($step->fraction); + } else { + $step->state = 'finished'; + } + + $this->add_step($step); + } + + protected function process0($step, $state) { + $this->triesleft = 1; + if (!empty($this->question->hints)) { + $this->triesleft += count($this->question->hints); + } + $step->data['-_triesleft'] = $this->triesleft; + parent::process0($step, $state); + } + + protected function process2($step, $state) { + if ($this->finishstate) { + $this->logger->log_assumption("Ignoring bogus save after submit, and before try again, in interactive attempt at question {$state->question} (question session {$this->qsession->id})"); + return; + } + parent::process2($step, $state); + } + + protected function process3($step, $state) { + if ($state->id == $this->qsession->newgraded) { + return $this->process6($step, $state); + } else { + return; + } + } + + protected function process6($step, $state) { + if ($this->finishstate) { + if (!$this->qtypeupdater->compare_answers($this->finishstate->answer, $state->answer) || + $this->finishstate->grade != $state->grade || + $this->finishstate->raw_grade != $state->raw_grade || + $this->finishstate->penalty != $state->penalty) { + throw new coding_exception("Two inconsistent finish states found for question session {$this->qsession->id}."); + } else if ($this->triesleft) { + $step->data = array('-finish' => '1'); + if ($this->question->maxmark > 0) { + $step->fraction = $state->grade / $this->question->maxmark; + $step->state = $this->graded_state_for_fraction($step->fraction); + } else { + $step->state = 'finished'; + } + $this->finishstate = $state; + $this->add_step($step); + $this->triesleft = 0; + return; + } else { + $this->logger->log_assumption("Ignoring extra finish states in attempt at question {$state->question}"); + return; + } + } + + if ($this->question->maxmark > 0) { + $step->fraction = $state->grade / $this->question->maxmark; + $step->state = $this->graded_state_for_fraction($state->raw_grade / $this->question->maxmark); + } else { + $step->state = 'finished'; + } + + $this->triesleft--; + $step->data['-submit'] = '1'; + if ($this->triesleft && $step->state != 'gradedright') { + $step->state = 'todo'; + $step->fraction = null; + $step->data['-_triesleft'] = $this->triesleft; + } else { + $this->triesleft = 0; + } + $this->finishstate = $state; + $this->add_step($step); + } + + protected function process7($step, $state) { + $this->unexpected_event($state); + } + + protected function process10($step, $state) { + if (!$this->finishstate) { + $oldcount = $this->sequencenumber; + $this->process6($step, $state); + if ($this->sequencenumber != $oldcount + 1) { + throw new coding_exception('Submit before try again did not keep the step.'); + } + $step = $this->make_step($state); + } + + $step->state = 'todo'; + $step->data = array('-tryagain' => 1); + $this->finishstate = null; + $this->add_step($step); + } +} + + +class qbehaviour_deferredfeedback_converter extends qbehaviour_converter { + protected function behaviour_name() { + return 'deferredfeedback'; + } + + protected function process6($step, $state) { + if (!$this->startstate) { + $this->logger->log_assumption("Ignoring bogus submit before open in attempt at question {$state->question}"); + // WTF, but this has happened a few times in our DB. It seems it is safe to ignore. + return; + } + + if ($this->finishstate) { + if ($this->finishstate->answer != $state->answer || + $this->finishstate->grade != $state->grade || + $this->finishstate->raw_grade != $state->raw_grade || + $this->finishstate->penalty != $state->penalty) { + $this->logger->log_assumption("Two inconsistent finish states found for question session {$this->qsession->id} in attempt at question {$state->question} keeping the later one."); + $this->discard_last_state(); + } else { + $this->logger->log_assumption("Ignoring extra finish states in attempt at question {$state->question}"); + return; + } + } + + if ($this->question->maxmark > 0) { + $step->fraction = $state->grade / $this->question->maxmark; + $step->state = $this->graded_state_for_fraction($step->fraction); + } else { + $step->state = 'finished'; + } + $step->data['-finish'] = '1'; + $this->finishstate = $state; + $this->add_step($step); + } + + protected function process7($step, $state) { + $this->unexpected_event($state); + } +} + + +abstract class qtype_updater { + /** @var question_engine_attempt_upgrader */ + protected $question; + protected $updater; + /** @var question_engine_assumption_logger */ + protected $logger; + + public function __construct($updater, $question, $logger) { + $this->updater = $updater; + $this->question = $question; + $this->logger = $logger; + } + + public function discard() { + // Help the garbage collector, which seems to be struggling. + $this->updater = null; + $this->question = null; + $this->logger = null; + } + + protected function to_text($html) { + return $this->updater->to_text($html); + } + + public function question_summary() { + return $this->to_text($this->question->questiontext); + } + + public function compare_answers($answer1, $answer2) { + return $answer1 == $answer2; + } + + public abstract function right_answer(); + public abstract function response_summary($state); + public abstract function was_answered($state); + public abstract function set_first_step_data_elements($state, &$data); + public abstract function set_data_elements_for_step($state, &$data); + public abstract function supply_missing_first_step_data(&$data); +} + +class qtype_multichoice_updater extends qtype_updater { + protected $order; + + public function right_answer() { + if ($this->question->options->single) { + foreach ($this->question->options->answers as $ans) { + if ($ans->fraction > 0.999) { + return $this->to_text($ans->answer); + } + } + + } else { + $rightbits = array(); + foreach ($this->question->options->answers as $ans) { + if ($ans->fraction >= 0.000001) { + $rightbits[] = $this->to_text($ans->answer); + } + } + return implode('; ', $rightbits); + } + } + + protected function explode_answer($answer) { + if (strpos($answer, ':') !== false) { + list($order, $responses) = explode(':', $answer); + return $responses; + } else { + // Sometimes, a bug means that a state is missing the