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 '
' . $flagcontent . "
\n"; + } + } + + /** + * Work out the actual img tag needed for the flag + * + * @param boolean $flagged whether the question is currently flagged. + * @param string $id an id to be added as an attribute to the img (optional). + * @return string the img tag. + */ + protected function get_flag_html($flagged, $id = '') { + global $CFG; + if ($id) { + $id = 'id="' . $id . '" '; + } + if ($flagged) { + $img = 'flagged.png'; + } else { + $img = 'unflagged.png'; + } + return '' . get_string('flagthisquestion', 'question') . ''; + } + + /** + * Generate the display of the formulation part of the question. This is the + * area that contains the quetsion text, and the controls for students to + * input their answers. Some question types also embed feedback, for + * example ticks and crosses, in this area. + * + * @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. + * @return HTML fragment. + */ + protected function formulation(question_attempt $qa, qbehaviour_renderer $behaviouroutput, + qtype_renderer $qtoutput, question_display_options $options) { + $output = ''; + $output .= html_writer::empty_tag('input', array( + 'type' => 'hidden', + 'name' => $qa->get_control_field_name('sequencecheck'), + 'value' => $qa->get_num_steps())); + $output .= $qtoutput->formulation_and_controls($qa, $options); + if ($options->clearwrong) { + $output .= $qtoutput->clear_wrong($qa); + } + $output .= html_writer::nonempty_tag('div', + $behaviouroutput->controls($qa, $options), array('class' => 'im-controls')); + return $output; + } + + /** + * Generate the display of the outcome part of the question. This is the + * area that contains the various forms of feedback. + * + * @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. + * @return HTML fragment. + */ + protected function outcome(question_attempt $qa, qbehaviour_renderer $behaviouroutput, + qtype_renderer $qtoutput, question_display_options $options) { + $output = ''; + $output .= html_writer::nonempty_tag('div', + $qtoutput->feedback($qa, $options), array('class' => 'feedback')); + $output .= html_writer::nonempty_tag('div', + $behaviouroutput->feedback($qa, $options), array('class' => 'im-feedback')); + return $output; + } + + /** + * Generate the display of the response history part of the question. This + * is the table showing all the steps the question has been through. + * + * @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. + * @return HTML fragment. + */ + protected function response_history(question_attempt $qa, qbehaviour_renderer $behaviouroutput, + qtype_renderer $qtoutput, question_display_options $options) { + + if (!$options->history) { + return ''; + } + + $table = new stdClass; + $table->head = array ( + get_string('step', 'question'), + get_string('time'), + get_string('action', 'question'), + get_string('state', 'question'), + ); + if ($options->marks >= question_display_options::MARK_AND_MAX) { + $table->head[] = get_string('marks', 'question'); + } + + foreach ($qa->get_full_step_iterator() as $i => $step) { + $stepno = $i + 1; + $rowclass = ''; + if ($stepno == $qa->get_num_steps()) { + $rowclass = 'current'; + } else if (!empty($options->questionreviewlink)) { + $stepno = link_to_popup_window($options->questionreviewlink . + '&slot=' . $qa->get_slot() . + '&step=' . $i, 'reviewquestion', $stepno, 450, 650, + get_string('reviewresponse', 'quiz'), 'none', true); + } + $user = new stdClass; + $user->id = $step->get_user_id(); + $row = array( + $stepno, + userdate($step->get_timecreated(), get_string('strftimedatetimeshort')), + s($qa->summarise_action($step)), + $step->get_state()->default_string(true), + ); + + if ($options->marks >= question_display_options::MARK_AND_MAX) { + $row[] = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp); + } + + $table->rowclass[] = $rowclass; + $table->data[] = $row; + } + + return html_writer::tag('h3', get_string('responsehistory', 'question'), + array('class' => 'responsehistoryheader')) . html_writer::tag('div', + print_table($table, true), array('class' => 'responsehistoryheader')); + } + +} diff --git a/question/engine/simpletest/helpers.php b/question/engine/simpletest/helpers.php new file mode 100644 index 00000000000..83b6553bf81 --- /dev/null +++ b/question/engine/simpletest/helpers.php @@ -0,0 +1,629 @@ +. + + +/** + * This file contains helper classes for testing 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 + */ + + +require_once(dirname(__FILE__) . '/../lib.php'); + + +/** + * Makes some protected methods of question_attempt public to facilitate testing. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class testable_question_attempt extends question_attempt { + public function add_step($step) {# + parent::add_step($step); + } + public function set_min_fraction($fraction) { + $this->minfraction = $fraction; + } + public function set_behaviour(question_behaviour $behaviour) { + $this->behaviour = $behaviour; + } +} + + +/** + * This class creates questions of various types, which can then be used when + * testing. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class test_question_maker { + const STANDARD_OVERALL_CORRECT_FEEDBACK = 'Well done!'; + const STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK = 'Parts, but only parts, of your response are correct.'; + const STANDARD_OVERALL_INCORRECT_FEEDBACK = 'That is not right at all.'; + + /** + * Initialise the common fields of a question of any type. + */ + public static function initialise_a_question($q) { + global $USER; + + $q->id = 0; + $q->category = 0; + $q->parent = 0; + $q->questiontextformat = FORMAT_HTML; + $q->defaultmark = 1; + $q->penalty = 0.3333333; + $q->length = 1; + $q->stamp = make_unique_id_code(); + $q->version = make_unique_id_code(); + $q->hidden = 0; + $q->timecreated = time(); + $q->timemodified = time(); + $q->createdby = $USER->id; + $q->modifiedby = $USER->id; + } + + /** + * Makes a truefalse question with correct answer true, defaultmark 1. + * @return qtype_truefalse_question + */ + public static function make_a_truefalse_question() { + question_bank::load_question_definition_classes('truefalse'); + $tf = new qtype_truefalse_question(); + self::initialise_a_question($tf); + $tf->name = 'True/false question'; + $tf->questiontext = 'The answer is true.'; + $tf->generalfeedback = 'You should have selected true.'; + $tf->penalty = 1; + $tf->qtype = question_bank::get_qtype('truefalse'); + + $tf->rightanswer = true; + $tf->truefeedback = 'This is the right answer.'; + $tf->falsefeedback = 'This is the wrong answer.'; + + return $tf; + } + + /** + * Makes a multichoice question with choices 'A', 'B' and 'C' shuffled. 'A' + * is correct, defaultmark 1. + * @return qtype_multichoice_single_question + */ + public static function make_a_multichoice_single_question() { + question_bank::load_question_definition_classes('multichoice'); + $mc = new qtype_multichoice_single_question(); + self::initialise_a_question($mc); + $mc->name = 'Multi-choice question, single response'; + $mc->questiontext = 'The answer is A.'; + $mc->generalfeedback = 'You should have selected A.'; + $mc->qtype = question_bank::get_qtype('multichoice'); + + $mc->shuffleanswers = 1; + $mc->answernumbering = 'abc'; + + $mc->answers = array( + 13 => new question_answer('A', 1, 'A is right'), + 14 => new question_answer('B', -0.3333333, 'B is wrong'), + 15 => new question_answer('C', -0.3333333, 'C is wrong'), + ); + + return $mc; + } + + /** + * Makes a multichoice question with choices 'A', 'B', 'C' and 'D' shuffled. + * 'A' and 'C' is correct, defaultmark 1. + * @return qtype_multichoice_multi_question + */ + public static function make_a_multichoice_multi_question() { + question_bank::load_question_definition_classes('multichoice'); + $mc = new qtype_multichoice_multi_question(); + self::initialise_a_question($mc); + $mc->name = 'Multi-choice question, multiple response'; + $mc->questiontext = 'The answer is A and C.'; + $mc->generalfeedback = 'You should have selected A and C.'; + $mc->qtype = question_bank::get_qtype('multichoice'); + + $mc->shuffleanswers = 1; + $mc->answernumbering = 'abc'; + + self::set_standard_combined_feedback_fields($mc); + + $mc->answers = array( + 13 => new question_answer('A', 0.5, 'A is part of the right answer'), + 14 => new question_answer('B', -1, 'B is wrong'), + 15 => new question_answer('C', 0.5, 'C is part of the right answer'), + 16 => new question_answer('D', -1, 'D is wrong'), + ); + + return $mc; + } + + /** + * Makes a matching question to classify 'Dog', 'Frog', 'Toad' and 'Cat' as + * 'Mammal', 'Amphibian' or 'Insect'. + * defaultmark 1. Stems are shuffled by default. + * @return qtype_match_question + */ + public static function make_a_matching_question() { + question_bank::load_question_definition_classes('match'); + $match = new qtype_match_question(); + self::initialise_a_question($match); + $match->name = 'Matching question'; + $match->questiontext = 'Classify the animals.'; + $match->generalfeedback = 'Frogs and toads are amphibians, the others are mammals.'; + $match->qtype = question_bank::get_qtype('match'); + + $match->shufflestems = 1; + + self::set_standard_combined_feedback_fields($match); + + $match->stems = array('', 'Dog', 'Frog', 'Toad', 'Cat'); + $match->choices = array('', 'Mammal', 'Amphibian', 'Insect'); + $match->right = array('', 1, 2, 2, 1); + unset($match->stems[0]); + unset($match->choices[0]); + unset($match->right[0]); + + return $match; + } + + /** + * Makes a shortanswer question with correct ansewer 'frog', partially + * correct answer 'toad' and defaultmark 1. + * @return qtype_shortanswer_question + */ + public static function make_a_shortanswer_question() { + question_bank::load_question_definition_classes('shortanswer'); + $sa = new qtype_shortanswer_question(); + self::initialise_a_question($sa); + $sa->name = 'Short answer question'; + $sa->questiontext = 'Name an amphibian: __________'; + $sa->generalfeedback = 'Generalfeedback: frog or toad would have been OK.'; + $sa->usecase = false; + $sa->answers = array( + new question_answer('frog', 1.0, 'Frog is a very good answer.'), + new question_answer('toad', 0.8, 'Toad is an OK good answer.'), + new question_answer('*', 0.0, 'That is a bad answer.'), + ); + $sa->qtype = question_bank::get_qtype('shortanswer'); + + return $sa; + } + + /** + * Makes a numerical question with correct ansewer 3.14, and various incorrect + * answers with different feedback. + * @return qtype_numerical_question + */ + public static function make_a_numerical_question() { + question_bank::load_question_definition_classes('numerical'); + $num = new qtype_numerical_question(); + self::initialise_a_question($num); + $num->name = 'Pi to two d.p.'; + $num->questiontext = 'What is pi to two d.p.?'; + $num->generalfeedback = 'Generalfeedback: 3.14 is the right answer.'; + $num->answers = array( + new qtype_numerical_answer('3.14', 1.0, 'Very good.', 0), + new qtype_numerical_answer('3.142', 0.0, 'Too accurate.', 0.005), + new qtype_numerical_answer('3.1', 0.0, 'Not accurate enough.', 0.05), + new qtype_numerical_answer('3', 0.0, 'Not accurate enough.', 0.5), + new qtype_numerical_answer('*', 0.0, 'Completely wrong.', 0), + ); + $num->qtype = question_bank::get_qtype('numerical'); + $num->ap = new qtype_numerical_answer_processor(array()); + + return $num; + } + + /** + * Makes a truefalse question with correct ansewer true, defaultmark 1. + * @return qtype_essay_question + */ + public static function make_an_essay_question() { + question_bank::load_question_definition_classes('essay'); + $essay = new qtype_essay_question(); + self::initialise_a_question($essay); + $essay->name = 'Essay question'; + $essay->questiontext = 'Write an essay.'; + $essay->generalfeedback = 'I hope you wrote an interesting essay.'; + $essay->penalty = 0; + $essay->qtype = question_bank::get_qtype('essay'); + + return $essay; + } + + /** + * Makes a truefalse question with correct ansewer true, defaultmark 1. + * @return question_truefalse + */ + public static function make_a_description_question() { + question_bank::load_question_definition_classes('description'); + $description = new qtype_description_question(); + self::initialise_a_question($description); + $description->name = 'Description question'; + $description->questiontext = 'This text tells you a bit about the next few questions in this quiz.'; + $description->generalfeedback = 'This is what this seciton of the quiz should have taught you.'; + $description->qtype = question_bank::get_qtype('description'); + + return $description; + } + + /** + * Add some standard overall feedback to a question. You need to use these + * specific feedback strings for the corresponding contains_..._feedback + * methods in {@link qbehaviour_walkthrough_test_base} to works. + * @param question_definition $q the question to add the feedback to. + */ + public static function set_standard_combined_feedback_fields($q) { + $q->correctfeedback = self::STANDARD_OVERALL_CORRECT_FEEDBACK; + $q->partiallycorrectfeedback = self::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK; + $q->shownumcorrect = true; + $q->incorrectfeedback = self::STANDARD_OVERALL_INCORRECT_FEEDBACK; + } +} + + +abstract class testing_db_record_builder { + public static function build_db_records(array $table) { + $columns = array_shift($table); + $records = array(); + foreach ($table as $row) { + if (count($row) != count($columns)) { + throw new Exception("Row contains the wrong number of fields."); + } + $rec = new stdClass; + foreach ($columns as $i => $name) { + $rec->$name = $row[$i]; + } + $records[] = $rec; + } + return $records; + } +} + + +class data_loading_method_test_base extends UnitTestCase { + public function build_db_records(array $table) { + return testing_db_record_builder::build_db_records($table); + } +} + + +class qbehaviour_walkthrough_test_base extends UnitTestCase { + /** @var question_display_options */ + protected $displayoptions; + /** @var question_usage_by_activity */ + protected $quba; + /** @var unknown_type integer */ + protected $slot; + + public function setUp() { + $this->displayoptions = new question_display_options(); + $this->quba = question_engine::make_questions_usage_by_activity('unit_test', + get_context_instance(CONTEXT_SYSTEM)); + } + + public function tearDown() { + $this->displayoptions = null; + $this->quba = null; + } + + protected function start_attempt_at_question($question, $preferredbehaviour, $maxmark = null) { + $this->quba->set_preferred_behaviour($preferredbehaviour); + $this->slot = $this->quba->add_question($question, $maxmark); + $this->quba->start_all_questions(); + } + protected function process_submission($data) { + $this->quba->process_action($this->slot, $data); + } + + protected function manual_grade($comment, $mark) { + $this->quba->manual_grade($this->slot, $comment, $mark); + } + + protected function check_current_state($state) { + $this->assertEqual($this->quba->get_question_state($this->slot), $state, 'Questions is in the wrong state: %s.'); + } + + protected function check_current_mark($mark) { + if (is_null($mark)) { + $this->assertNull($this->quba->get_question_mark($this->slot)); + } else { + if ($mark == 0) { + // PHP will think a null mark and a mark of 0 are equal, + // so explicity check not null in this case. + $this->assertNotNull($this->quba->get_question_mark($this->slot)); + } + $this->assertWithinMargin($mark, $this->quba->get_question_mark($this->slot), + 0.000001, 'Expected mark and actual mark differ: %s.'); + } + } + + /** + * @param $condition one or more Expectations. (users varargs). + */ + protected function check_current_output() { + $html = $this->quba->render_question($this->slot, $this->displayoptions); + foreach (func_get_args() as $condition) { + $this->assert($condition, $html); + } + } + + protected function get_question_attempt() { + return $this->quba->get_question_attempt($this->slot); + } + + protected function get_step_count() { + return $this->get_question_attempt()->get_num_steps(); + } + + protected function check_step_count($expectednumsteps) { + $this->assertEqual($expectednumsteps, $this->get_step_count()); + } + + protected function get_step($stepnum) { + return $this->get_question_attempt()->get_step($stepnum); + } + + protected function get_contains_question_text_expectation($question) { + return new PatternExpectation('/' . preg_quote($question->questiontext) . '/'); + } + + protected function get_contains_general_feedback_expectation() { + return new PatternExpectation('/' . preg_quote($question->generalfeedback) . '/'); + } + + protected function get_does_not_contain_correctness_expectation() { + return new NoPatternExpectation('/class=\"correctness/'); + } + + protected function get_contains_correct_expectation() { + return new PatternExpectation('/' . preg_quote(get_string('correct', 'question')) . '/'); + } + + protected function get_contains_partcorrect_expectation() { + return new PatternExpectation('/' . preg_quote(get_string('partiallycorrect', 'question')) . '/'); + } + + protected function get_contains_incorrect_expectation() { + return new PatternExpectation('/' . preg_quote(get_string('incorrect', 'question')) . '/'); + } + + protected function get_contains_standard_correct_combined_feedback_expectation() { + return new PatternExpectation('/' . preg_quote(test_question_maker::STANDARD_OVERALL_CORRECT_FEEDBACK) . '/'); + } + + protected function get_contains_standard_partiallycorrect_combined_feedback_expectation() { + return new PatternExpectation('/' . preg_quote(test_question_maker::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK) . '/'); + } + + protected function get_contains_standard_incorrect_combined_feedback_expectation() { + return new PatternExpectation('/' . preg_quote(test_question_maker::STANDARD_OVERALL_INCORRECT_FEEDBACK) . '/'); + } + + protected function get_does_not_contain_feedback_expectation() { + return new NoPatternExpectation('/class="feedback"/'); + } + + protected function get_does_not_contain_num_parts_correct() { + return new NoPatternExpectation('/class="numpartscorrect"/'); + } + + protected function get_contains_num_parts_correct($num) { + $a = new stdClass; + $a->num = $num; + return new PatternExpectation('/
' . + preg_quote(get_string('yougotnright', 'question', $a)) . '/'); + } + + protected function get_does_not_contain_specific_feedback_expectation() { + return new NoPatternExpectation('/class="specificfeedback"/'); + } + + protected function get_contains_validation_error_expectation() { + return new ContainsTagWithAttribute('div', 'class', 'validationerror'); + } + + protected function get_does_not_contain_validation_error_expectation() { + return new NoPatternExpectation('/class="validationerror"/'); + } + + protected function get_contains_mark_summary($mark) { + $a = new stdClass; + $a->mark = format_float($mark, $this->displayoptions->markdp); + $a->max = format_float($this->quba->get_question_max_mark($this->slot), + $this->displayoptions->markdp); + return new PatternExpectation('/' . + preg_quote(get_string('markoutofmax', 'question', $a)) . '/'); + } + + protected function get_contains_marked_out_of_summary() { + $max = format_float($this->quba->get_question_max_mark($this->slot), + $this->displayoptions->markdp); + return new PatternExpectation('/' . + preg_quote(get_string('markedoutofmax', 'question', $max)) . '/'); + } + + protected function get_does_not_contain_mark_summary() { + return new NoPatternExpectation('/
/'); + } + + protected function get_contains_checkbox_expectation($baseattr, $enabled, $checked) { + $expectedattributes = $baseattr; + $forbiddenattributes = array(); + $expectedattributes['type'] = 'checkbox'; + if ($enabled === true) { + $forbiddenattributes['disabled'] = 'disabled'; + } else if ($enabled === false) { + $expectedattributes['disabled'] = 'disabled'; + } + if ($checked === true) { + $expectedattributes['checked'] = 'checked'; + } else if ($checked === false) { + $forbiddenattributes['checked'] = 'checked'; + } + return new ContainsTagWithAttributes('input', $expectedattributes, $forbiddenattributes); + } + + protected function get_contains_mc_checkbox_expectation($index, $enabled = null, $checked = null) { + return $this->get_contains_checkbox_expectation(array( + 'name' => $this->quba->get_field_prefix($this->slot) . $index, + 'value' => 1, + ), $enabled, $checked); + } + + protected function get_contains_radio_expectation($baseattr, $enabled, $checked) { + $expectedattributes = $baseattr; + $forbiddenattributes = array(); + $expectedattributes['type'] = 'radio'; + if ($enabled === true) { + $forbiddenattributes['disabled'] = 'disabled'; + } else if ($enabled === false) { + $expectedattributes['disabled'] = 'disabled'; + } + if ($checked === true) { + $expectedattributes['checked'] = 'checked'; + } else if ($checked === false) { + $forbiddenattributes['checked'] = 'checked'; + } + return new ContainsTagWithAttributes('input', $expectedattributes, $forbiddenattributes); + } + + protected function get_contains_mc_radio_expectation($index, $enabled = null, $checked = null) { + return $this->get_contains_radio_expectation(array( + 'name' => $this->quba->get_field_prefix($this->slot) . 'answer', + 'value' => $index, + ), $enabled, $checked); + } + + protected function get_contains_hidden_expectation($name, $value = null) { + $expectedattributes = array('type' => 'hidden', 'name' => s($name)); + if (!is_null($value)) { + $expectedattributes['value'] = s($value); + } + return new ContainsTagWithAttributes('input', $expectedattributes); + } + + protected function get_does_not_contain_hidden_expectation($name, $value = null) { + $expectedattributes = array('type' => 'hidden', 'name' => s($name)); + if (!is_null($value)) { + $expectedattributes['value'] = s($value); + } + return new DoesNotContainTagWithAttributes('input', $expectedattributes); + } + + protected function get_contains_tf_true_radio_expectation($enabled = null, $checked = null) { + return $this->get_contains_radio_expectation(array( + 'name' => $this->quba->get_field_prefix($this->slot) . 'answer', + 'value' => 1, + ), $enabled, $checked); + } + + protected function get_contains_tf_false_radio_expectation($enabled = null, $checked = null) { + return $this->get_contains_radio_expectation(array( + 'name' => $this->quba->get_field_prefix($this->slot) . 'answer', + 'value' => 0, + ), $enabled, $checked); + } + + protected function get_contains_cbm_radio_expectation($certainty, $enabled = null, $checked = null) { + return $this->get_contains_radio_expectation(array( + 'name' => $this->quba->get_field_prefix($this->slot) . '-certainty', + 'value' => $certainty, + ), $enabled, $checked); + } + + protected function get_contains_button_expectation($name, $value = null, $enabled = null) { + $expectedattributes = array( + 'type' => 'submit', + 'name' => $name, + ); + $forbiddenattributes = array(); + if (!is_null($value)) { + $expectedattributes['value'] = $value; + } + if ($enabled === true) { + $forbiddenattributes['disabled'] = 'disabled'; + } else if ($enabled === false) { + $expectedattributes['disabled'] = 'disabled'; + } + return new ContainsTagWithAttributes('input', $expectedattributes, $forbiddenattributes); + } + + protected function get_contains_submit_button_expectation($enabled = null) { + return $this->get_contains_button_expectation( + $this->quba->get_field_prefix($this->slot) . '-submit', null, $enabled); + } + + protected function get_tries_remaining_expectation($n) { + return new PatternExpectation('/' . preg_quote(get_string('triesremaining', 'qbehaviour_interactive', $n)) . '/'); + } + + protected function get_invalid_answer_expectation() { + return new PatternExpectation('/' . preg_quote(get_string('invalidanswer', 'question')) . '/'); + } + + protected function get_contains_try_again_button_expectation($enabled = null) { + $expectedattributes = array( + 'type' => 'submit', + 'name' => $this->quba->get_field_prefix($this->slot) . '-tryagain', + ); + $forbiddenattributes = array(); + if ($enabled === true) { + $forbiddenattributes['disabled'] = 'disabled'; + } else if ($enabled === false) { + $expectedattributes['disabled'] = 'disabled'; + } + return new ContainsTagWithAttributes('input', $expectedattributes, $forbiddenattributes); + } + + protected function get_does_not_contain_try_again_button_expectation() { + return new NoPatternExpectation('/name="' . + $this->quba->get_field_prefix($this->slot) . '-tryagain"/'); + } + + protected function get_contains_select_expectation($name, $choices, + $selected = null, $enabled = null) { + $fullname = $this->quba->get_field_prefix($this->slot) . $name; + return new ContainsSelectExpectation($fullname, $choices, $selected, $enabled); + } + + protected function get_mc_right_answer_index($mc) { + $order = $mc->get_order($this->get_question_attempt()); + foreach ($order as $i => $ansid) { + if ($mc->answers[$ansid]->fraction == 1) { + return $i; + } + } + $this->fail('This multiple choice question does not seem to have a right answer!'); + } + + protected function get_no_hint_visible_expectation() { + return new NoPatternExpectation('/class="hint"/'); + } + + protected function get_contains_hint_expectation($hinttext) { + // Does not currently verify hint text. + return new ContainsTagWithAttribute('div', 'class', 'hint'); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testdatalib.php b/question/engine/simpletest/testdatalib.php new file mode 100644 index 00000000000..8b20d941e9e --- /dev/null +++ b/question/engine/simpletest/testdatalib.php @@ -0,0 +1,137 @@ +. + + +/** + * This file contains tests for the question_state class. + * + * @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__) . '/../lib.php'); + +class qubaid_condition_test extends UnitTestCase { + + protected function check_typical_question_attempts_query( + qubaid_condition $qubaids, $expectedsql, $expectedparams) { + $sql = "SELECT qa.id, qa.maxmark + FROM {$qubaids->from_question_attempts('qa')} + WHERE {$qubaids->where()} AND qa.slot = :slot"; + $this->assertEqual($expectedsql, $sql); + + $params = $qubaids->from_where_params(); + $params['slot'] = 1; + $this->assertEqual($expectedparams, $params); + } + + protected function check_typical_in_query(qubaid_condition $qubaids, $expectedsql, $expectedparams) { + global $CFG; + $sql = "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}question_attempts qa + WHERE qa.questionusageid {$qubaids->usage_id_in()}"; + $this->assertEqual($expectedsql, $sql); + + $this->assertEqual($expectedparams, $qubaids->usage_id_in_params()); + } + + public function test_qubaid_list_one_join() { + global $CFG; + $qubaids = new qubaid_list(array(1)); + $this->check_typical_question_attempts_query($qubaids, + "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}question_attempts qa + WHERE qa.questionusageid = :qubaid0000 AND qa.slot = :slot", + array('qubaid0000' => 1, 'slot' => 1)); + } + + public function test_qubaid_list_several_join() { + global $CFG; + $qubaids = new qubaid_list(array(1, 3, 7)); + $this->check_typical_question_attempts_query($qubaids, + "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}question_attempts qa + WHERE qa.questionusageid IN (:qubaid0000,:qubaid0001,:qubaid0002) AND qa.slot = :slot", + array('qubaid0000' => 1, 'qubaid0001' => 3, 'qubaid0002' => 7, 'slot' => 1)); + } + + public function test_qubaid_join() { + global $CFG; + $qubaids = new qubaid_join("{$CFG->prefix}other_table ot", 'ot.usageid', 'ot.id = 1'); + + $this->check_typical_question_attempts_query($qubaids, + "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}other_table ot + JOIN {$CFG->prefix}question_attempts qa ON qa.questionusageid = ot.usageid + WHERE ot.id = 1 AND qa.slot = :slot", array('slot' => 1)); + } + + public function test_qubaid_join_no_where_join() { + global $CFG; + $qubaids = new qubaid_join("{$CFG->prefix}other_table ot", 'ot.usageid'); + + $this->check_typical_question_attempts_query($qubaids, + "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}other_table ot + JOIN {$CFG->prefix}question_attempts qa ON qa.questionusageid = ot.usageid + WHERE 1 = 1 AND qa.slot = :slot", array('slot' => 1)); + } + + public function test_qubaid_list_one_in() { + global $CFG; + $qubaids = new qubaid_list(array(1)); + $this->check_typical_in_query($qubaids, + "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}question_attempts qa + WHERE qa.questionusageid = :qubaid0000", array('qubaid0000' => 1)); + } + + public function test_qubaid_list_several_in() { + global $CFG; + $qubaids = new qubaid_list(array(1, 2, 3)); + $this->check_typical_in_query($qubaids, + "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}question_attempts qa + WHERE qa.questionusageid IN (:qubaid0000,:qubaid0001,:qubaid0002)", + array('qubaid0000' => 1, 'qubaid0001' => 2, 'qubaid0002' => 3)); + } + + public function test_qubaid_join_in() { + global $CFG; + $qubaids = new qubaid_join("{$CFG->prefix}other_table ot", 'ot.usageid', 'ot.id = 1'); + + $this->check_typical_in_query($qubaids, + "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}question_attempts qa + WHERE qa.questionusageid IN (SELECT ot.usageid FROM {$CFG->prefix}other_table ot WHERE ot.id = 1)", + array()); + } + + public function test_qubaid_join_no_where_in() { + global $CFG; + $qubaids = new qubaid_join("{$CFG->prefix}other_table ot", 'ot.usageid'); + + $this->check_typical_in_query($qubaids, + "SELECT qa.id, qa.maxmark + FROM {$CFG->prefix}question_attempts qa + WHERE qa.questionusageid IN (SELECT ot.usageid FROM {$CFG->prefix}other_table ot WHERE 1 = 1)", + array()); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testquestionattempt.php b/question/engine/simpletest/testquestionattempt.php new file mode 100644 index 00000000000..d13f339a79f --- /dev/null +++ b/question/engine/simpletest/testquestionattempt.php @@ -0,0 +1,347 @@ +. + + +/** + * This file contains tests for the question_attempt class. + * + * Action methods like start, process_action and finish are assumed to be + * tested by testintegration.php. + * + * @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__) . '/../lib.php'); +require_once(dirname(__FILE__) . '/helpers.php'); + +class question_attempt_test extends UnitTestCase { + private $question; + private $usageid; + private $qa; + + public function setUp() { + $this->question = test_question_maker::make_a_description_question(); + $this->question->defaultmark = 3; + $this->usageid = 13; + $this->qa = new question_attempt($this->question, $this->usageid); + } + + public function tearDown() { + $this->question = null; + $this->useageid = null; + $this->qa = null; + } + + public function test_constructor_sets_maxmark() { + $qa = new question_attempt($this->question, $this->usageid); + $this->assertIdentical($this->question, $qa->get_question()); + $this->assertEqual(3, $qa->get_max_mark()); + } + + public function test_maxmark_beats_default_mark() { + $qa = new question_attempt($this->question, $this->usageid, null, 2); + $this->assertEqual(2, $qa->get_max_mark()); + } + + public function test_get_set_number_in_usage() { + $this->qa->set_number_in_usage(7); + $this->assertEqual(7, $this->qa->get_slot()); + } + + public function test_fagged_initially_false() { + $this->assertEqual(false, $this->qa->is_flagged()); + } + + public function test_set_is_flagged() { + $this->qa->set_flagged(true); + $this->assertEqual(true, $this->qa->is_flagged()); + } + + public function test_get_qt_field_name() { + $name = $this->qa->get_qt_field_name('test'); + $this->assertPattern('/^' . preg_quote($this->qa->get_field_prefix()) . '/', $name); + $this->assertPattern('/_test$/', $name); + } + + public function test_get_behaviour_field_name() { + $name = $this->qa->get_behaviour_field_name('test'); + $this->assertPattern('/^' . preg_quote($this->qa->get_field_prefix()) . '/', $name); + $this->assertPattern('/_-test$/', $name); + } + + public function test_get_field_prefix() { + $this->qa->set_number_in_usage(7); + $name = $this->qa->get_field_prefix(); + $this->assertPattern('/' . preg_quote($this->usageid) . '/', $name); + $this->assertPattern('/' . preg_quote($this->qa->get_slot()) . '/', $name); + } + + public function test_get_submitted_var_not_present_var_returns_null() { + $this->assertNull(question_attempt::get_submitted_var( + 'reallyunlikelyvariablename', PARAM_BOOL)); + } + + public function test_get_submitted_var_param_mark_not_present() { + $this->assertNull(question_attempt::get_submitted_var( + 'name', question_attempt::PARAM_MARK, array())); + } + + public function test_get_submitted_var_param_mark_blank() { + $this->assertIdentical('', question_attempt::get_submitted_var( + 'name', question_attempt::PARAM_MARK, array('name' => ''))); + } + + public function test_get_submitted_var_param_mark_number() { + $this->assertIdentical(123.0, question_attempt::get_submitted_var( + 'name', question_attempt::PARAM_MARK, array('name' => '123'))); + } + + public function test_get_submitted_var_param_mark_invalid() { + $this->assertIdentical(0.0, question_attempt::get_submitted_var( + 'name', question_attempt::PARAM_MARK, array('name' => 'frog'))); + } +} + + +/** + * These tests use a standard fixture of a question_attempt with three steps. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_attempt_with_steps_test extends UnitTestCase { + private $question; + private $qa; + + public function setUp() { + $this->question = test_question_maker::make_a_description_question(); + $this->qa = new testable_question_attempt($this->question, 0, null, 2); + for ($i = 0; $i < 3; $i++) { + $step = new question_attempt_step(array('i' => $i)); + $this->qa->add_step($step); + } + } + + public function tearDown() { + $this->qa = null; + } + + public function test_get_step_before_start() { + $this->expectException(); + $step = $this->qa->get_step(-1); + } + + public function test_get_step_at_start() { + $step = $this->qa->get_step(0); + $this->assertEqual(0, $step->get_qt_var('i')); + } + + public function test_get_step_at_end() { + $step = $this->qa->get_step(2); + $this->assertEqual(2, $step->get_qt_var('i')); + } + + public function test_get_step_past_end() { + $this->expectException(); + $step = $this->qa->get_step(3); + } + + public function test_get_num_steps() { + $this->assertEqual(3, $this->qa->get_num_steps()); + } + + public function test_get_last_step() { + $step = $this->qa->get_last_step(); + $this->assertEqual(2, $step->get_qt_var('i')); + } + + public function test_get_last_qt_var_there1() { + $this->assertEqual(2, $this->qa->get_last_qt_var('i')); + } + + public function test_get_last_qt_var_there2() { + $this->qa->get_step(0)->set_qt_var('_x', 'a value'); + $this->assertEqual('a value', $this->qa->get_last_qt_var('_x')); + } + + public function test_get_last_qt_var_missing() { + $this->assertNull($this->qa->get_last_qt_var('notthere')); + } + + public function test_get_last_qt_var_missing_default() { + $this->assertEqual('default', $this->qa->get_last_qt_var('notthere', 'default')); + } + + public function test_get_last_behaviour_var_missing() { + $this->assertNull($this->qa->get_last_qt_var('notthere')); + } + + public function test_get_last_behaviour_var_there() { + $this->qa->get_step(1)->set_behaviour_var('_x', 'a value'); + $this->assertEqual('a value', '' . $this->qa->get_last_behaviour_var('_x')); + } + + public function test_get_state_gets_state_of_last() { + $this->qa->get_step(2)->set_state(question_state::$gradedright); + $this->qa->get_step(1)->set_state(question_state::$gradedwrong); + $this->assertEqual(question_state::$gradedright, $this->qa->get_state()); + } + + public function test_get_mark_gets_mark_of_last() { + $this->assertEqual(2, $this->qa->get_max_mark()); + $this->qa->get_step(2)->set_fraction(0.5); + $this->qa->get_step(1)->set_fraction(0.1); + $this->assertEqual(1, $this->qa->get_mark()); + } + + public function test_get_fraction_gets_fraction_of_last() { + $this->qa->get_step(2)->set_fraction(0.5); + $this->qa->get_step(1)->set_fraction(0.1); + $this->assertEqual(0.5, $this->qa->get_fraction()); + } + + public function test_get_fraction_returns_null_if_none() { + $this->assertNull($this->qa->get_fraction()); + } + + public function test_format_mark() { + $this->qa->get_step(2)->set_fraction(0.5); + $this->assertEqual('1.00', $this->qa->format_mark(2)); + } + + public function test_format_max_mark() { + $this->assertEqual('2.0000000', $this->qa->format_max_mark(7)); + } + + public function test_get_min_fraction() { + $this->qa->set_min_fraction(-1); + $this->assertEqual(-1, $this->qa->get_min_fraction(0)); + } + + public function test_cannot_get_min_fraction_before_start() { + $qa = new question_attempt($this->question, null); + $this->expectException(); + $qa->get_min_fraction(); + } +} + + +class question_attempt_db_test extends data_loading_method_test_base { + public function test_load() { + $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, 'deferredfeedback', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null), + array(2, 1, 1, 1, 'deferredfeedback', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', null, 1256233705, 1, 'answer', '1'), + array(3, 1, 1, 1, 'deferredfeedback', -1, 2.0000000, 0.0000000, 1, '', '', '', 1256233790, 3, 2, 'complete', null, 1256233710, 1, 'answer', '0'), + array(4, 1, 1, 1, 'deferredfeedback', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 4, 3, 'complete', null, 1256233715, 1, 'answer', '1'), + array(5, 1, 1, 1, 'deferredfeedback', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 5, 4, 'gradedright', 1.0000000, 1256233720, 1, '-finish', '1'), + array(6, 1, 1, 1, 'deferredfeedback', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-comment', 'Not good enough!'), + array(7, 1, 1, 1, 'deferredfeedback', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-mark', '1'), + array(8, 1, 1, 1, 'deferredfeedback', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-maxmark', '2'), + )); + + $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($question->questiontext, $qa->get_question()->questiontext); + + $this->assertEqual(6, $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(), $step->get_all_data()); + + $step = $qa->get_step(1); + $this->assertEqual(question_state::$complete, $step->get_state()); + $this->assertNull($step->get_fraction()); + $this->assertEqual(1256233705, $step->get_timecreated()); + $this->assertEqual(1, $step->get_user_id()); + $this->assertEqual(array('answer' => '1'), $step->get_all_data()); + + $step = $qa->get_step(2); + $this->assertEqual(question_state::$complete, $step->get_state()); + $this->assertNull($step->get_fraction()); + $this->assertEqual(1256233710, $step->get_timecreated()); + $this->assertEqual(1, $step->get_user_id()); + $this->assertEqual(array('answer' => '0'), $step->get_all_data()); + + $step = $qa->get_step(3); + $this->assertEqual(question_state::$complete, $step->get_state()); + $this->assertNull($step->get_fraction()); + $this->assertEqual(1256233715, $step->get_timecreated()); + $this->assertEqual(1, $step->get_user_id()); + $this->assertEqual(array('answer' => '1'), $step->get_all_data()); + + $step = $qa->get_step(4); + $this->assertEqual(question_state::$gradedright, $step->get_state()); + $this->assertEqual(1, $step->get_fraction()); + $this->assertEqual(1256233720, $step->get_timecreated()); + $this->assertEqual(1, $step->get_user_id()); + $this->assertEqual(array('-finish' => '1'), $step->get_all_data()); + + $step = $qa->get_step(5); + $this->assertEqual(question_state::$mangrpartial, $step->get_state()); + $this->assertEqual(0.5, $step->get_fraction()); + $this->assertEqual(1256233790, $step->get_timecreated()); + $this->assertEqual(1, $step->get_user_id()); + $this->assertEqual(array('-comment' => 'Not good enough!', '-mark' => '1', '-maxmark' => '2'), + $step->get_all_data()); + } + + public function test_load_missing_question() { + $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, 'deferredfeedback', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null), + )); + + question_bank::start_unit_test(); + $qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback'); + question_bank::end_unit_test(); + + $missingq = question_bank::get_qtype('missingtype')->make_deleted_instance(-1, 2); + $this->assertEqual($missingq, $qa->get_question()); + + $this->assertEqual(1, $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(), $step->get_all_data()); + } +} diff --git a/question/engine/simpletest/testquestionattemptiterator.php b/question/engine/simpletest/testquestionattemptiterator.php new file mode 100644 index 00000000000..79e46cd347d --- /dev/null +++ b/question/engine/simpletest/testquestionattemptiterator.php @@ -0,0 +1,110 @@ +. + + +/** + * This file contains tests for the question_attempt_iterator class. + * + * @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__) . '/../lib.php'); +require_once(dirname(__FILE__) . '/helpers.php'); + + +class question_attempt_iterator_test extends UnitTestCase { + private $quba; + private $qas = array(); + private $iterator; + + public function setUp() { + $this->quba = question_engine::make_questions_usage_by_activity('unit_test', + get_context_instance(CONTEXT_SYSTEM)); + $this->quba->set_preferred_behaviour('deferredfeedback'); + + $slot = $this->quba->add_question(test_question_maker::make_a_description_question()); + $this->qas[$slot] = $this->quba->get_question_attempt($slot); + + $slot = $this->quba->add_question(test_question_maker::make_a_description_question()); + $this->qas[$slot] = $this->quba->get_question_attempt($slot); + + $this->iterator = $this->quba->get_attempt_iterator(); + } + + public function tearDown() { + $this->quba = null; + $this->iterator = null; + } + + public function test_foreach_loop() { + $i = 1; + foreach ($this->iterator as $key => $qa) { + $this->assertEqual($i, $key); + $this->assertIdentical($this->qas[$i], $qa); + $i++; + } + $this->assertEqual(3, $i); + } + + public function test_offsetExists_before_start() { + $this->assertFalse(isset($this->iterator[0])); + } + + public function test_offsetExists_at_start() { + $this->assertTrue(isset($this->iterator[1])); + } + + public function test_offsetExists_at_endt() { + $this->assertTrue(isset($this->iterator[2])); + } + + public function test_offsetExists_past_end() { + $this->assertFalse(isset($this->iterator[3])); + } + + public function test_offsetGet_before_start() { + $this->expectException(); + $step = $this->iterator[0]; + } + + public function test_offsetGet_at_start() { + $this->assertIdentical($this->qas[1], $this->iterator[1]); + } + + public function test_offsetGet_at_end() { + $this->assertIdentical($this->qas[2], $this->iterator[2]); + } + + public function test_offsetGet_past_end() { + $this->expectException(); + $step = $this->iterator[3]; + } + + public function test_cannot_set() { + $this->expectException(); + $this->iterator[0] = null; + } + + public function test_cannot_unset() { + $this->expectException(); + unset($this->iterator[2]); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testquestionattemptstep.php b/question/engine/simpletest/testquestionattemptstep.php new file mode 100644 index 00000000000..e9039879a27 --- /dev/null +++ b/question/engine/simpletest/testquestionattemptstep.php @@ -0,0 +1,170 @@ +. + + +/** + * This file contains tests for the question_attempt_step class. + * + * @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__) . '/../lib.php'); +require_once(dirname(__FILE__) . '/helpers.php'); + +class question_attempt_step_test extends UnitTestCase { + public function test_initial_state_unprocessed() { + $step = new question_attempt_step(); + $this->assertEqual(question_state::$unprocessed, $step->get_state()); + } + + public function test_get_set_state() { + $step = new question_attempt_step(); + $step->set_state(question_state::$gradedright); + $this->assertEqual(question_state::$gradedright, $step->get_state()); + } + + public function test_initial_fraction_null() { + $step = new question_attempt_step(); + $this->assertNull($step->get_fraction()); + } + + public function test_get_set_fraction() { + $step = new question_attempt_step(); + $step->set_fraction(0.5); + $this->assertEqual(0.5, $step->get_fraction()); + } + + public function test_has_var() { + $step = new question_attempt_step(array('x' => 1, '-y' => 'frog')); + $this->assertTrue($step->has_qt_var('x')); + $this->assertTrue($step->has_behaviour_var('y')); + $this->assertFalse($step->has_qt_var('y')); + $this->assertFalse($step->has_behaviour_var('x')); + } + + public function test_get_var() { + $step = new question_attempt_step(array('x' => 1, '-y' => 'frog')); + $this->assertEqual('1', $step->get_qt_var('x')); + $this->assertEqual('frog', $step->get_behaviour_var('y')); + $this->assertNull($step->get_qt_var('y')); + } + + public function test_set_var() { + $step = new question_attempt_step(); + $step->set_qt_var('_x', 1); + $step->set_behaviour_var('_x', 2); + $this->assertEqual('1', $step->get_qt_var('_x')); + $this->assertEqual('2', $step->get_behaviour_var('_x')); + } + + public function test_cannot_set_qt_var_without_underscore() { + $step = new question_attempt_step(); + $this->expectException(); + $step->set_qt_var('x', 1); + } + + public function test_cannot_set_behaviour_var_without_underscore() { + $step = new question_attempt_step(); + $this->expectException(); + $step->set_behaviour_var('x', 1); + } + + public function test_get_data() { + $step = new question_attempt_step(array('x' => 1, '-y' => 'frog', ':flagged' => 1)); + $this->assertEqual(array('x' => '1'), $step->get_qt_data()); + $this->assertEqual(array('y' => 'frog'), $step->get_behaviour_data()); + $this->assertEqual(array('x' => 1, '-y' => 'frog', ':flagged' => 1), $step->get_all_data()); + } + + public function test_get_submitted_data() { + $step = new question_attempt_step(array('x' => 1, '-y' => 'frog')); + $step->set_qt_var('_x', 1); + $step->set_behaviour_var('_x', 2); + $this->assertEqual(array('x' => 1, '-y' => 'frog'), $step->get_submitted_data()); + } + + public function test_constructor_default_params() { + global $USER; + $step = new question_attempt_step(); + $this->assertWithinMargin(time(), $step->get_timecreated(), 5); + $this->assertEqual($USER->id, $step->get_user_id()); + $this->assertEqual(array(), $step->get_qt_data()); + $this->assertEqual(array(), $step->get_behaviour_data()); + + } + + public function test_constructor_given_params() { + global $USER; + $step = new question_attempt_step(array(), 123, 5); + $this->assertEqual(123, $step->get_timecreated()); + $this->assertEqual(5, $step->get_user_id()); + $this->assertEqual(array(), $step->get_qt_data()); + $this->assertEqual(array(), $step->get_behaviour_data()); + + } +} + + +class question_attempt_step_db_test extends data_loading_method_test_base { + public function test_load_with_data() { + $records = $this->build_db_records(array( + array('id', 'attemptstepid', 'questionattemptid', 'sequencenumber', 'state', 'fraction', 'timecreated', 'userid', 'name', 'value'), + array( 1, 1, 1, 0, 'todo', null, 1256228502, 13, null, null), + array( 2, 2, 1, 1, 'complete', null, 1256228505, 13, 'x', 'a'), + array( 3, 2, 1, 1, 'complete', null, 1256228505, 13, '_y', '_b'), + array( 4, 2, 1, 1, 'complete', null, 1256228505, 13, '-z', '!c'), + array( 5, 2, 1, 1, 'complete', null, 1256228505, 13, '-_t', '!_d'), + array( 6, 3, 1, 2, 'gradedright', 1.0, 1256228515, 13, '-finish', '1'), + )); + + $step = question_attempt_step::load_from_records($records, 2); + $this->assertEqual(question_state::$complete, $step->get_state()); + $this->assertNull($step->get_fraction()); + $this->assertEqual(1256228505, $step->get_timecreated()); + $this->assertEqual(13, $step->get_user_id()); + $this->assertEqual(array('x' => 'a', '_y' => '_b', '-z' => '!c', '-_t' => '!_d'), $step->get_all_data()); + } + + public function test_load_without_data() { + $records = $this->build_db_records(array( + array('id', 'attemptstepid', 'questionattemptid', 'sequencenumber', 'state', 'fraction', 'timecreated', 'userid', 'name', 'value'), + array( 2, 2, 1, 1, 'complete', null, 1256228505, 13, null, null), + )); + + $step = question_attempt_step::load_from_records($records, 2); + $this->assertEqual(question_state::$complete, $step->get_state()); + $this->assertNull($step->get_fraction()); + $this->assertEqual(1256228505, $step->get_timecreated()); + $this->assertEqual(13, $step->get_user_id()); + $this->assertEqual(array(), $step->get_all_data()); + } + + public function test_load_dont_be_too_greedy() { + $records = $this->build_db_records(array( + array('id', 'attemptstepid', 'questionattemptid', 'sequencenumber', 'state', 'fraction', 'timecreated', 'userid', 'name', 'value'), + array( 1, 1, 1, 0, 'todo', null, 1256228502, 13, 'x', 'right'), + array( 2, 2, 2, 0, 'complete', null, 1256228505, 13, 'x', 'wrong'), + )); + + $step = question_attempt_step::load_from_records($records, 1); + $this->assertEqual(array('x' => 'right'), $step->get_all_data()); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testquestionattemptstepiterator.php b/question/engine/simpletest/testquestionattemptstepiterator.php new file mode 100644 index 00000000000..224510ec399 --- /dev/null +++ b/question/engine/simpletest/testquestionattemptstepiterator.php @@ -0,0 +1,129 @@ +. + + +/** + * This file contains tests for the question_attempt_step_iterator class. + * + * @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__) . '/../lib.php'); +require_once(dirname(__FILE__) . '/helpers.php'); + + +class question_attempt_step_iterator_test extends UnitTestCase { + private $qa; + private $iterator; + + public function setUp() { + $question = test_question_maker::make_a_description_question(); + $this->qa = new testable_question_attempt($question, 0); + for ($i = 0; $i < 3; $i++) { + $step = new question_attempt_step(array('i' => $i)); + $this->qa->add_step($step); + } + $this->iterator = $this->qa->get_step_iterator(); + } + + public function tearDown() { + $this->qa = null; + $this->iterator = null; + } + + public function test_foreach_loop() { + $i = 0; + foreach ($this->iterator as $key => $step) { + $this->assertEqual($i, $key); + $this->assertEqual($i, $step->get_qt_var('i')); + $i++; + } + } + + public function test_foreach_loop_add_step_during() { + $i = 0; + foreach ($this->iterator as $key => $step) { + $this->assertEqual($i, $key); + $this->assertEqual($i, $step->get_qt_var('i')); + $i++; + if ($i == 2) { + $step = new question_attempt_step(array('i' => 3)); + $this->qa->add_step($step); + } + } + $this->assertEqual(4, $i); + } + + public function test_reverse_foreach_loop() { + $i = 2; + foreach ($this->qa->get_reverse_step_iterator() as $key => $step) { + $this->assertEqual($i, $key); + $this->assertEqual($i, $step->get_qt_var('i')); + $i--; + } + } + + public function test_offsetExists_before_start() { + $this->assertFalse(isset($this->iterator[-1])); + } + + public function test_offsetExists_at_start() { + $this->assertTrue(isset($this->iterator[0])); + } + + public function test_offsetExists_at_endt() { + $this->assertTrue(isset($this->iterator[2])); + } + + public function test_offsetExists_past_end() { + $this->assertFalse(isset($this->iterator[3])); + } + + public function test_offsetGet_before_start() { + $this->expectException(); + $step = $this->iterator[-1]; + } + + public function test_offsetGet_at_start() { + $step = $this->iterator[0]; + $this->assertEqual(0, $step->get_qt_var('i')); + } + + public function test_offsetGet_at_end() { + $step = $this->iterator[2]; + $this->assertEqual(2, $step->get_qt_var('i')); + } + + public function test_offsetGet_past_end() { + $this->expectException(); + $step = $this->iterator[3]; + } + + public function test_cannot_set() { + $this->expectException(); + $this->iterator[0] = null; + } + + public function test_cannot_unset() { + $this->expectException(); + unset($this->iterator[2]); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testquestioncbm.php b/question/engine/simpletest/testquestioncbm.php new file mode 100644 index 00000000000..5274cc7c6b1 --- /dev/null +++ b/question/engine/simpletest/testquestioncbm.php @@ -0,0 +1,40 @@ +. + + +/** + * This file contains tests for the question_cbm class. + * + * @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__) . '/../lib.php'); + +class question_cbm_test extends UnitTestCase { + public function test_adjust_fraction() { + $this->assertWithinMargin(0, question_cbm::adjust_fraction(0, question_cbm::LOW), 0.0000001); + $this->assertWithinMargin(-2/3, question_cbm::adjust_fraction(0, question_cbm::MED), 0.0000001); + $this->assertWithinMargin(-2, question_cbm::adjust_fraction(0, question_cbm::HIGH), 0.0000001); + $this->assertWithinMargin(1/3, question_cbm::adjust_fraction(1, question_cbm::LOW), 0.0000001); + $this->assertWithinMargin(2/3, question_cbm::adjust_fraction(1, question_cbm::MED), 0.0000001); + $this->assertWithinMargin(1, question_cbm::adjust_fraction(1, question_cbm::HIGH), 0.0000001); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testquestionengine.php b/question/engine/simpletest/testquestionengine.php new file mode 100644 index 00000000000..84ef20038c1 --- /dev/null +++ b/question/engine/simpletest/testquestionengine.php @@ -0,0 +1,94 @@ +. + + +/** + * This file contains tests for the question_engine class. + * + * @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__) . '/../lib.php'); + +class question_engine_test extends UnitTestCase { + + public function setUp() { + + } + + public function tearDown() { + + } + + public function test_load_behaviour_class() { + // Exercise SUT + question_engine::load_behaviour_class('deferredfeedback'); + // Verify + $this->assertTrue(class_exists('qbehaviour_deferredfeedback')); + } + + public function test_load_behaviour_class_missing() { + // Set expectation. + $this->expectException(); + // Exercise SUT + question_engine::load_behaviour_class('nonexistantbehaviour'); + } + + public function test_get_behaviour_unused_display_options() { + $this->assertEqual(array(), question_engine::get_behaviour_unused_display_options('interactive')); + $this->assertEqual(array('correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'), + question_engine::get_behaviour_unused_display_options('deferredfeedback')); + $this->assertEqual(array('correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'), + question_engine::get_behaviour_unused_display_options('deferredcbm')); + $this->assertEqual(array('correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'), + question_engine::get_behaviour_unused_display_options('manualgraded')); + } + + public function test_sort_behaviours() { + $in = array('b1' => 'Behave 1', 'b2' => 'Behave 2', 'b3' => 'Behave 3', 'b4' => 'Behave 4', 'b5' => 'Behave 5', 'b6' => 'Behave 6'); + + $out = array('b1' => 'Behave 1', 'b2' => 'Behave 2', 'b3' => 'Behave 3', 'b4' => 'Behave 4', 'b5' => 'Behave 5', 'b6' => 'Behave 6'); + $this->assertIdentical($out, question_engine::sort_behaviours($in, '', '', '')); + + $this->assertIdentical($out, question_engine::sort_behaviours($in, '', 'b4', 'b4')); + + $out = array('b4' => 'Behave 4', 'b5' => 'Behave 5', 'b6' => 'Behave 6'); + $this->assertIdentical($out, question_engine::sort_behaviours($in, '', 'b1,b2,b3,b4', 'b4')); + + $out = array('b6' => 'Behave 6', 'b1' => 'Behave 1', 'b4' => 'Behave 4'); + $this->assertIdentical($out, question_engine::sort_behaviours($in, 'b6, b1, b4', 'b1, b2, b3, b4, b5', 'b4')); + + $out = array('b6' => 'Behave 6', 'b5' => 'Behave 5', 'b4' => 'Behave 4'); + $this->assertIdentical($out, question_engine::sort_behaviours($in, 'b6, b5, b4', 'b1, b2, b3', 'b4')); + + $out = array('b1' => 'Behave 1', 'b6' => 'Behave 6', 'b5' => 'Behave 5', 'b4' => 'Behave 4'); + $this->assertIdentical($out, question_engine::sort_behaviours($in, 'b1, b6, b5', 'b1, b2, b3, b4, b5', 'b4')); + + $out = array('b2' => 'Behave 2', 'b4' => 'Behave 4', 'b6' => 'Behave 6'); + $this->assertIdentical($out, question_engine::sort_behaviours($in, 'b2, b4, b6', 'b1, b3, b5', 'b2')); + + // Ignore unknown input in the order argument. + $this->assertIdentical($in, question_engine::sort_behaviours($in, 'unknown', '', '')); + + // Ignore unknown input in the disabled argument. + $this->assertIdentical($in, question_engine::sort_behaviours($in, '', 'unknown', '')); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testquestionstate.php b/question/engine/simpletest/testquestionstate.php new file mode 100644 index 00000000000..eb0d0551e27 --- /dev/null +++ b/question/engine/simpletest/testquestionstate.php @@ -0,0 +1,154 @@ +. + + +/** + * This file contains tests for the question_state class. + * + * @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__) . '/../lib.php'); +require_once($CFG->libdir . '/questionlib.php'); + +class question_state_test extends UnitTestCase { + public function test_is_active() { + $this->assertFalse(question_state::$notstarted->is_active()); + $this->assertFalse(question_state::$unprocessed->is_active()); + $this->assertTrue(question_state::$todo->is_active()); + $this->assertTrue(question_state::$invalid->is_active()); + $this->assertTrue(question_state::$complete->is_active()); + $this->assertFalse(question_state::$needsgrading->is_active()); + $this->assertFalse(question_state::$finished->is_active()); + $this->assertFalse(question_state::$gaveup->is_active()); + $this->assertFalse(question_state::$gradedwrong->is_active()); + $this->assertFalse(question_state::$gradedpartial->is_active()); + $this->assertFalse(question_state::$gradedright->is_active()); + $this->assertFalse(question_state::$manfinished->is_active()); + $this->assertFalse(question_state::$mangaveup->is_active()); + $this->assertFalse(question_state::$mangrwrong->is_active()); + $this->assertFalse(question_state::$mangrpartial->is_active()); + $this->assertFalse(question_state::$mangrright->is_active()); + } + + public function test_is_finished() { + $this->assertFalse(question_state::$notstarted->is_finished()); + $this->assertFalse(question_state::$unprocessed->is_finished()); + $this->assertFalse(question_state::$todo->is_finished()); + $this->assertFalse(question_state::$invalid->is_finished()); + $this->assertFalse(question_state::$complete->is_finished()); + $this->assertTrue(question_state::$needsgrading->is_finished()); + $this->assertTrue(question_state::$finished->is_finished()); + $this->assertTrue(question_state::$gaveup->is_finished()); + $this->assertTrue(question_state::$gradedwrong->is_finished()); + $this->assertTrue(question_state::$gradedpartial->is_finished()); + $this->assertTrue(question_state::$gradedright->is_finished()); + $this->assertTrue(question_state::$manfinished->is_finished()); + $this->assertTrue(question_state::$mangaveup->is_finished()); + $this->assertTrue(question_state::$mangrwrong->is_finished()); + $this->assertTrue(question_state::$mangrpartial->is_finished()); + $this->assertTrue(question_state::$mangrright->is_finished()); + } + + public function test_is_graded() { + $this->assertFalse(question_state::$notstarted->is_graded()); + $this->assertFalse(question_state::$unprocessed->is_graded()); + $this->assertFalse(question_state::$todo->is_graded()); + $this->assertFalse(question_state::$invalid->is_graded()); + $this->assertFalse(question_state::$complete->is_graded()); + $this->assertFalse(question_state::$needsgrading->is_graded()); + $this->assertFalse(question_state::$finished->is_graded()); + $this->assertFalse(question_state::$gaveup->is_graded()); + $this->assertTrue(question_state::$gradedwrong->is_graded()); + $this->assertTrue(question_state::$gradedpartial->is_graded()); + $this->assertTrue(question_state::$gradedright->is_graded()); + $this->assertFalse(question_state::$manfinished->is_graded()); + $this->assertFalse(question_state::$mangaveup->is_graded()); + $this->assertTrue(question_state::$mangrwrong->is_graded()); + $this->assertTrue(question_state::$mangrpartial->is_graded()); + $this->assertTrue(question_state::$mangrright->is_graded()); + } + + public function test_is_commented() { + $this->assertFalse(question_state::$notstarted->is_commented()); + $this->assertFalse(question_state::$unprocessed->is_commented()); + $this->assertFalse(question_state::$todo->is_commented()); + $this->assertFalse(question_state::$invalid->is_commented()); + $this->assertFalse(question_state::$complete->is_commented()); + $this->assertFalse(question_state::$needsgrading->is_commented()); + $this->assertFalse(question_state::$finished->is_commented()); + $this->assertFalse(question_state::$gaveup->is_commented()); + $this->assertFalse(question_state::$gradedwrong->is_commented()); + $this->assertFalse(question_state::$gradedpartial->is_commented()); + $this->assertFalse(question_state::$gradedright->is_commented()); + $this->assertTrue(question_state::$manfinished->is_commented()); + $this->assertTrue(question_state::$mangaveup->is_commented()); + $this->assertTrue(question_state::$mangrwrong->is_commented()); + $this->assertTrue(question_state::$mangrpartial->is_commented()); + $this->assertTrue(question_state::$mangrright->is_commented()); + } + + public function test_graded_state_for_fraction() { + $this->assertEqual(question_state::$gradedwrong, question_state::graded_state_for_fraction(-1)); + $this->assertEqual(question_state::$gradedwrong, question_state::graded_state_for_fraction(0)); + $this->assertEqual(question_state::$gradedpartial, question_state::graded_state_for_fraction(0.000001)); + $this->assertEqual(question_state::$gradedpartial, question_state::graded_state_for_fraction(0.999999)); + $this->assertEqual(question_state::$gradedright, question_state::graded_state_for_fraction(1)); + } + + public function test_manually_graded_state_for_other_state() { + $this->assertEqual(question_state::$manfinished, + question_state::$finished->corresponding_commented_state(null)); + $this->assertEqual(question_state::$mangaveup, + question_state::$gaveup->corresponding_commented_state(null)); + $this->assertEqual(question_state::$manfinished, + question_state::$manfinished->corresponding_commented_state(null)); + $this->assertEqual(question_state::$mangaveup, + question_state::$mangaveup->corresponding_commented_state(null)); + $this->assertEqual(question_state::$needsgrading, + question_state::$mangrright->corresponding_commented_state(null)); + $this->assertEqual(question_state::$needsgrading, + question_state::$mangrright->corresponding_commented_state(null)); + + $this->assertEqual(question_state::$mangrwrong, + question_state::$gaveup->corresponding_commented_state(0)); + $this->assertEqual(question_state::$mangrwrong, + question_state::$needsgrading->corresponding_commented_state(0)); + $this->assertEqual(question_state::$mangrwrong, + question_state::$gradedwrong->corresponding_commented_state(0)); + $this->assertEqual(question_state::$mangrwrong, + question_state::$gradedpartial->corresponding_commented_state(0)); + $this->assertEqual(question_state::$mangrwrong, + question_state::$gradedright->corresponding_commented_state(0)); + $this->assertEqual(question_state::$mangrwrong, + question_state::$mangrright->corresponding_commented_state(0)); + $this->assertEqual(question_state::$mangrwrong, + question_state::$mangrpartial->corresponding_commented_state(0)); + $this->assertEqual(question_state::$mangrwrong, + question_state::$mangrright->corresponding_commented_state(0)); + + $this->assertEqual(question_state::$mangrpartial, + question_state::$gradedpartial->corresponding_commented_state(0.5)); + + $this->assertEqual(question_state::$mangrright, + question_state::$gradedpartial->corresponding_commented_state(1)); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testquestionusagebyactivity.php b/question/engine/simpletest/testquestionusagebyactivity.php new file mode 100644 index 00000000000..eb4261def07 --- /dev/null +++ b/question/engine/simpletest/testquestionusagebyactivity.php @@ -0,0 +1,155 @@ +. + + +/** + * This file contains tests for the question_usage_by_activity class. + * + * @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__) . '/../lib.php'); +require_once(dirname(__FILE__) . '/helpers.php'); + + +class question_usage_by_activity_test extends UnitTestCase { + + public function test_set_get_preferred_model() { + // Set up + $quba = question_engine::make_questions_usage_by_activity('unit_test', + get_context_instance(CONTEXT_SYSTEM)); + + // Exercise SUT and verify. + $quba->set_preferred_behaviour('deferredfeedback'); + $this->assertEqual('deferredfeedback', $quba->get_preferred_behaviour()); + } + + public function test_set_get_id() { + // Set up + $quba = question_engine::make_questions_usage_by_activity('unit_test', + get_context_instance(CONTEXT_SYSTEM)); + + // Exercise SUT and verify + $quba->set_id_from_database(123); + $this->assertEqual(123, $quba->get_id()); + } + + public function test_fake_id() { + // Set up + $quba = question_engine::make_questions_usage_by_activity('unit_test', + get_context_instance(CONTEXT_SYSTEM)); + + // Exercise SUT and verify + $this->assertTrue($quba->get_id()); + } + + public function test_create_usage_and_add_question() { + // Exercise SUT + $context = get_context_instance(CONTEXT_SYSTEM); + $quba = question_engine::make_questions_usage_by_activity('unit_test', $context); + $quba->set_preferred_behaviour('deferredfeedback'); + $tf = test_question_maker::make_a_truefalse_question(); + $slot = $quba->add_question($tf); + + // Verify. + $this->assertEqual($slot, 1); + $this->assertEqual('unit_test', $quba->get_owning_component()); + $this->assertIdentical($context, $quba->get_owning_context()); + $this->assertEqual($quba->question_count(), 1); + $this->assertEqual($quba->get_question_state($slot), question_state::$notstarted); + } + + public function test_get_question() { + // Set up. + $quba = question_engine::make_questions_usage_by_activity('unit_test', + get_context_instance(CONTEXT_SYSTEM)); + $quba->set_preferred_behaviour('deferredfeedback'); + $tf = test_question_maker::make_a_truefalse_question(); + $slot = $quba->add_question($tf); + + // Exercise SUT and verify. + $this->assertIdentical($tf, $quba->get_question($slot)); + + $this->expectException(); + $quba->get_question($slot + 1); + } + + public function test_extract_responses() { + // Start a deferred feedback attempt with CBM and add the question to it. + $tf = test_question_maker::make_a_truefalse_question(); + $quba = question_engine::make_questions_usage_by_activity('unit_test', + get_context_instance(CONTEXT_SYSTEM)); + $quba->set_preferred_behaviour('deferredcbm'); + $slot = $quba->add_question($tf); + $quba->start_all_questions(); + + // Prepare data to be submitted + $prefix = $quba->get_field_prefix($slot); + $answername = $prefix . 'answer'; + $certaintyname = $prefix . '-certainty'; + $getdata = array( + $answername => 1, + $certaintyname => 3, + 'irrelevant' => 'should be ignored', + ); + + // Exercise SUT + $submitteddata = $quba->extract_responses($slot, $getdata); + + // Verify. + $this->assertEqual(array('answer' => 1, '-certainty' => 3), $submitteddata); + } + + public function test_access_out_of_sequence_throws_exception() { + // Start a deferred feedback attempt with CBM and add the question to it. + $tf = test_question_maker::make_a_truefalse_question(); + $quba = question_engine::make_questions_usage_by_activity('unit_test', + get_context_instance(CONTEXT_SYSTEM)); + $quba->set_preferred_behaviour('deferredcbm'); + $slot = $quba->add_question($tf); + $quba->start_all_questions(); + + // Prepare data to be submitted + $prefix = $quba->get_field_prefix($slot); + $answername = $prefix . 'answer'; + $certaintyname = $prefix . '-certainty'; + $postdata = array( + $answername => 1, + $certaintyname => 3, + $prefix . ':sequencecheck' => 1, + 'irrelevant' => 'should be ignored', + ); + + // Exercise SUT - no exception yet. + $quba->process_all_actions($slot, $postdata); + + $postdata = array( + $answername => 1, + $certaintyname => 3, + $prefix . ':sequencecheck' => 3, + 'irrelevant' => 'should be ignored', + ); + + // Exercise SUT - now it should fail. + $this->expectException('question_out_of_sequence_exception'); + $quba->process_all_actions($slot, $postdata); + } +} \ No newline at end of file diff --git a/question/engine/simpletest/testquestionutils.php b/question/engine/simpletest/testquestionutils.php new file mode 100644 index 00000000000..87ee68397b3 --- /dev/null +++ b/question/engine/simpletest/testquestionutils.php @@ -0,0 +1,186 @@ +. + + +/** + * This file contains tests for the {@link question_utils} class. + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once(dirname(__FILE__) . '/../lib.php'); + +class question_utils_test extends UnitTestCase { + public function test_arrays_have_same_keys_and_values() { + $this->assertTrue(question_utils::arrays_have_same_keys_and_values( + array(), + array())); + $this->assertTrue(question_utils::arrays_have_same_keys_and_values( + array('key' => 1), + array('key' => '1'))); + $this->assertFalse(question_utils::arrays_have_same_keys_and_values( + array(), + array('key' => 1))); + $this->assertFalse(question_utils::arrays_have_same_keys_and_values( + array('key' => 2), + array('key' => 1))); + $this->assertFalse(question_utils::arrays_have_same_keys_and_values( + array('key' => 1), + array('otherkey' => 1))); + $this->assertFalse(question_utils::arrays_have_same_keys_and_values( + array('sub0' => '2', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'), + array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); + } + + public function test_arrays_same_at_key() { + $this->assertTrue(question_utils::arrays_same_at_key( + array(), + array(), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key( + array(), + array('key' => 1), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key( + array('key' => 1), + array(), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key( + array('key' => 1), + array('key' => 1), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key( + array('key' => 1), + array('key' => 2), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key( + array('key' => 1), + array('key' => '1'), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key( + array('key' => 0), + array('key' => ''), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key( + array(), + array('key' => ''), + 'key')); + } + + public function test_arrays_same_at_key_missing_is_blank() { + $this->assertTrue(question_utils::arrays_same_at_key_missing_is_blank( + array(), + array(), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key_missing_is_blank( + array(), + array('key' => 1), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key_missing_is_blank( + array('key' => 1), + array(), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key_missing_is_blank( + array('key' => 1), + array('key' => 1), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key_missing_is_blank( + array('key' => 1), + array('key' => 2), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key_missing_is_blank( + array('key' => 1), + array('key' => '1'), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key_missing_is_blank( + array('key' => '0'), + array('key' => ''), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key_missing_is_blank( + array(), + array('key' => ''), + 'key')); + } + + public function test_arrays_same_at_key_integer() { + $this->assertTrue(question_utils::arrays_same_at_key_integer( + array(), + array(), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key_integer( + array(), + array('key' => 1), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key_integer( + array('key' => 1), + array(), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key_integer( + array('key' => 1), + array('key' => 1), + 'key')); + $this->assertFalse(question_utils::arrays_same_at_key_integer( + array('key' => 1), + array('key' => 2), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key_integer( + array('key' => 1), + array('key' => '1'), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key_integer( + array('key' => '0'), + array('key' => ''), + 'key')); + $this->assertTrue(question_utils::arrays_same_at_key_integer( + array(), + array('key' => 0), + 'key')); + } + + public function test_int_to_roman() { + $this->assertIdentical('i', question_utils::int_to_roman(1)); + $this->assertIdentical('iv', question_utils::int_to_roman(4)); + $this->assertIdentical('v', question_utils::int_to_roman(5)); + $this->assertIdentical('vi', question_utils::int_to_roman(6)); + $this->assertIdentical('ix', question_utils::int_to_roman(9)); + $this->assertIdentical('xi', question_utils::int_to_roman(11)); + $this->assertIdentical('xlviii', question_utils::int_to_roman(48)); + $this->assertIdentical('lxxxvii', question_utils::int_to_roman(87)); + $this->assertIdentical('c', question_utils::int_to_roman(100)); + $this->assertIdentical('mccxxxiv', question_utils::int_to_roman(1234)); + $this->assertIdentical('mmmcmxcix', question_utils::int_to_roman(3999)); + } + + public function test_int_to_roman_too_small() { + $this->expectException(); + question_utils::int_to_roman(0); + } + + public function test_int_to_roman_too_big() { + $this->expectException(); + question_utils::int_to_roman(4000); + } + + public function test_int_to_roman_not_int() { + $this->expectException(); + question_utils::int_to_roman(1.5); + } +} \ No newline at end of file diff --git a/question/engine/states.php b/question/engine/states.php new file mode 100644 index 00000000000..0fde0fc6368 --- /dev/null +++ b/question/engine/states.php @@ -0,0 +1,460 @@ +. + + +/** + * This defines the states a question can be in. + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * An enumeration representing the states a question can be in after a + * {@link question_attempt_step}. + * + * There are also some useful methods for testing and manipulating states. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +abstract class question_state { + /**#@+ + * Specific question_state instances. + */ + public static $notstarted; + public static $unprocessed; + public static $todo; + public static $invalid; + public static $complete; + public static $needsgrading; + public static $finished; + public static $gaveup; + public static $gradedwrong; + public static $gradedpartial; + public static $gradedright; + public static $manfinished; + public static $mangaveup; + public static $mangrwrong; + public static $mangrpartial; + public static $mangrright; + /**#@+-*/ + + protected function __construct() { + } + + public static function init() { + $us = new ReflectionClass('question_state'); + foreach ($us->getStaticProperties() as $name => $notused) { + $class = 'question_state_' . $name; + $states[$name] = new $class(); + self::$$name = $states[$name]; + } + } + + /** + * Get all the states in an array. + * @return of question_state objects. + */ + public static function get_all() { + $states = array(); + $us = new ReflectionClass('question_state'); + foreach ($us->getStaticProperties() as $name => $notused) { + $states[] = self::$$name; + } + return $states; + } + + /** + * Get all the states in an array. + * @param string $summarystate one of the four summary states + * inprogress, needsgrading, manuallygraded or autograded. + * @return arrau of the corresponding states. + */ + public static function get_all_for_summary_state($summarystate) { + $states = array(); + foreach (self::get_all() as $state) { + if ($state->get_summary_state() == $summarystate) { + $states[] = $state; + } + } + if (empty($states)) { + throw new Exception('unknown summary state ' . $summarystate); + } + return $states; + } + + /** + * @return string convert this state to a string. + */ + public function __toString() { + return substr(get_class($this), 15); + } + + /** + * @param string $name a state name. + * @return question_state the state with that name. + */ + public static function get($name) { + return self::$$name; + } + + /** + * Is this state one of the ones that mean the question attempt is in progress? + * That is, started, but no finished. + * @return boolean + */ + public function is_active() { + return false; + } + + /** + * Is this state one of the ones that mean the question attempt is finished? + * That is, no further interaction possible, apart from manual grading. + * @return boolean + */ + public function is_finished() { + return true; + } + + /** + * Is this state one of the ones that mean the question attempt has been graded? + * @return boolean + */ + public function is_graded() { + return false; + } + + /** + * Is this state one of the ones that mean the question attempt has been graded? + * @return boolean + */ + public function is_correct() { + return false; + } + + /** + * Is this state one of the ones that mean the question attempt has been graded? + * @return boolean + */ + public function is_partially_correct() { + return false; + } + + /** + * Is this state one of the ones that mean the question attempt has been graded? + * @return boolean + */ + public function is_incorrect() { + return false; + } + + /** + * Is this state one of the ones that mean the question attempt has been graded? + * @return boolean + */ + public function is_gave_up() { + return false; + } + + /** + * Is this state one of the ones that mean the question attempt has had a manual comment added? + * @return boolean + */ + public function is_commented() { + return false; + } + + /** + * Each state can be categorised into one of four categories: + * inprogress, needsgrading, manuallygraded or autograded. + * @return string which category this state falls into. + */ + public function get_summary_state() { + if (!$this->is_finished()) { + return 'inprogress'; + } else if ($this == self::$needsgrading) { + return 'needsgrading'; + } else if ($this->is_commented()) { + return 'manuallygraded'; + } else { + return 'autograded'; + } + } + + /** + * Return the appropriate graded state based on a fraction. That is 0 or less + * is $graded_incorrect, 1 is $graded_correct, otherwise it is $graded_partcorrect. + * Appropriate allowance is made for rounding float values. + * + * @param number $fraction the grade, on the fraction scale. + * @return integer one of the state constants. + */ + public static function graded_state_for_fraction($fraction) { + if ($fraction < 0.000001) { + return self::$gradedwrong; + } else if ($fraction > 0.999999) { + return self::$gradedright; + } else { + return self::$gradedpartial; + } + } + + /** + * Return the appropriate manually graded state based on a fraction. That is 0 or less + * is $manually_graded_incorrect, 1 is $manually_graded_correct, otherwise it is + * $manually_graded_partcorrect. Appropriate allowance is made for rounding float values. + * + * @param number $fraction the grade, on the fraction scale. + * @return integer one of the state constants. + */ + public static function manually_graded_state_for_fraction($fraction) { + if (is_null($fraction)) { + return self::$needsgrading; + } else if ($fraction < 0.000001) { + return self::$mangrwrong; + } else if ($fraction > 0.999999) { + return self::$mangrright; + } else { + return self::$mangrpartial; + } + } + + /** + * Compute an appropriate state to move to after a manual comment has been + * added to this state. + * @param number $fraction the manual grade (if any) on the fraction scale. + * @return integer the new state. + */ + public function corresponding_commented_state($fraction) { + throw new Exception('Unexpected question state.'); + } + + /** + * Return an appropriate CSS class name ''/'correct'/'partiallycorrect'/'incorrect', + * for a state. + * @return string + */ + public function get_feedback_class() { + return ''; + } + + /** + * Return the name of an appropriate string to look up in the question + * language pack for a state. This is used, for example, by + * {@link question_behaviour::get_state_string()}. However, behaviours + * sometimes change this default string for soemthing more specific. + * + * @param boolean $showcorrectness Whether right/partial/wrong states should + * be distinguised, or just treated as 'complete'. + * @return string the name of a string that can be looked up in the 'question' + * lang pack, or used as a CSS class name, etc. + */ + public abstract function get_state_class($showcorrectness); + + /** + * The result of doing get_string on the result of {@link get_state_class()}. + * + * @param boolean $showcorrectness Whether right/partial/wrong states should + * be distinguised. + * @return string a string from the lang pack that can be used in the UI. + */ + public function default_string($showcorrectness) { + return get_string($this->get_state_class($showcorrectness), 'question'); + } +} + + +/**#@+ + * Specific question_state subclasses. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_state_notstarted extends question_state { + public function is_finished() { + return false; + } + public function get_state_class($showcorrectness) { + throw new Exception('Unexpected question state.'); + } +} +class question_state_unprocessed extends question_state { + public function is_finished() { + return false; + } + public function get_state_class($showcorrectness) { + throw new Exception('Unexpected question state.'); + } +} +class question_state_todo extends question_state { + public function is_active() { + return true; + } + public function is_finished() { + return false; + } + public function get_state_class($showcorrectness) { + return 'notyetanswered'; + } +} +class question_state_invalid extends question_state { + public function is_active() { + return true; + } + public function is_finished() { + return false; + } + public function get_state_class($showcorrectness) { + return 'invalidanswer'; + } +} +class question_state_complete extends question_state { + public function is_active() { + return true; + } + public function is_finished() { + return false; + } + public function get_state_class($showcorrectness) { + return 'answersaved'; + } +} +class question_state_needsgrading extends question_state { + public function get_state_class($showcorrectness) { + return 'requiresgrading'; + } + public function corresponding_commented_state($fraction) { + return self::manually_graded_state_for_fraction($fraction); + } +} +class question_state_finished extends question_state { + public function get_state_class($showcorrectness) { + return 'complete'; + } + public function corresponding_commented_state($fraction) { + return self::$manfinished; + } +} +class question_state_gaveup extends question_state { + public function is_gave_up() { + return true; + } + public function get_feedback_class() { + return 'incorrect'; + } + public function get_state_class($showcorrectness) { + return 'notanswered'; + } + public function corresponding_commented_state($fraction) { + if (is_null($fraction)) { + return self::$mangaveup; + } else { + return self::manually_graded_state_for_fraction($fraction); + } + } +} +abstract class question_state_graded extends question_state { + public function is_graded() { + return true; + } + public function get_state_class($showcorrectness) { + if ($showcorrectness) { + return $this->get_feedback_class(); + } else { + return 'complete'; + } + } + public function corresponding_commented_state($fraction) { + return self::manually_graded_state_for_fraction($fraction); + } +} +class question_state_gradedwrong extends question_state_graded { + public function is_incorrect() { + return true; + } + public function get_feedback_class() { + return 'incorrect'; + } +} +class question_state_gradedpartial extends question_state_graded { + public function is_graded() { + return true; + } + public function is_partially_correct() { + return true; + } + public function get_feedback_class() { + return 'partiallycorrect'; + } +} +class question_state_gradedright extends question_state_graded { + public function is_graded() { + return true; + } + public function is_correct() { + return true; + } + public function get_feedback_class() { + return 'correct'; + } +} +class question_state_manfinished extends question_state_finished { + public function is_commented() { + return true; + } +} +class question_state_mangaveup extends question_state_gaveup { + public function is_commented() { + return true; + } +} +abstract class question_state_manuallygraded extends question_state_graded { + public function is_commented() { + return true; + } +} +class question_state_mangrwrong extends question_state_manuallygraded { + public function is_incorrect() { + return false; + } + public function get_feedback_class() { + return 'incorrect'; + } +} +class question_state_mangrpartial extends question_state_manuallygraded { + public function is_partially_correct() { + return true; + } + public function get_feedback_class() { + return 'partiallycorrect'; + } +} +class question_state_mangrright extends question_state_manuallygraded { + public function is_correct() { + return true; + } + public function get_feedback_class() { + return 'correct'; + } +} +/**#@-*/ +question_state::init(); diff --git a/question/engine/upgradefromoldqe/upgrade.php b/question/engine/upgradefromoldqe/upgrade.php new file mode 100644 index 00000000000..a87a16e8741 --- /dev/null +++ b/question/engine/upgradefromoldqe/upgrade.php @@ -0,0 +1,1969 @@ +. + + +/** + * This file contains the code required to upgrade all the attempt data from + * old versions of Moodle into the tables used by the new question engine. + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +global $CFG; +require_once($CFG->libdir . '/questionlib.php'); + + +/** + * This class serves to record all the assumptions that the code had to make + * during the question engine database database upgrade, to facilitate reviewing them. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_engine_assumption_logger { + protected $handle; + protected $attemptid; + + public function __construct() { + global $CFG; + make_upload_directory('upgradelogs'); + $this->handle = fopen($CFG->dataroot . '/upgradelogs/qe_' . + date('Ymd-Hi') . '.html', 'a'); + fwrite($this->handle, 'Question engine upgrade assumptions ' . + date('Ymd-Hi') . '

Question engine upgrade assumptions ' . + date('Ymd-Hi') . "

\n\n"); + } + + public function set_current_attempt_id($id) { + $this->attemptid = $id; + } + + public function log_assumption($description, $quizattemptid = null) { + global $CFG; + $message = '

' . $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 : bit, + // We need to deal with that. + $this->logger->log_assumption("Dealing with missing order information + in attempt at multiple choice question {$this->question->id}"); + return $answer; + } + } + + public function response_summary($state) { + $responses = $this->explode_answer($state->answer); + if ($this->question->options->single) { + if (is_numeric($responses)) { + if (array_key_exists($responses, $this->question->options->answers)) { + return $this->to_text($this->question->options->answers[$responses]->answer); + } else { + $this->logger->log_assumption("Dealing with a place where the + student selected a choice that was later deleted for + multiple choice question {$this->question->id}"); + return '[CHOICE THAT WAS LATER DELETED]'; + } + } else { + return null; + } + + } else { + if (!empty($responses)) { + $responses = explode(',', $responses); + $bits = array(); + foreach ($responses as $response) { + if (array_key_exists($response, $this->question->options->answers)) { + $bits[] = $this->to_text($this->question->options->answers[$response]->answer); + } else { + $this->logger->log_assumption("Dealing with a place where the + student selected a choice that was later deleted for + multiple choice question {$this->question->id}"); + $bits[] = '[CHOICE THAT WAS LATER DELETED]'; + } + } + return implode('; ', $bits); + } else { + return null; + } + } + } + + public function was_answered($state) { + $responses = $this->explode_answer($state->answer); + if ($this->question->options->single) { + return is_numeric($responses); + } else { + return !empty($responses); + } + } + + public function set_first_step_data_elements($state, &$data) { + if (!$state->answer) { + return; + } + list($order, $responses) = explode(':', $state->answer); + $data['_order'] = $order; + $this->order = explode(',', $order); + } + + public function supply_missing_first_step_data(&$data) { + $data['_order'] = implode(',', array_keys($this->question->options->answers)); + } + + public function set_data_elements_for_step($state, &$data) { + $responses = $this->explode_answer($state->answer); + if ($this->question->options->single) { + if (is_numeric($responses)) { + $flippedorder = array_combine(array_values($this->order), array_keys($this->order)); + if (array_key_exists($responses, $flippedorder)) { + $data['answer'] = $flippedorder[$responses]; + } else { + $data['answer'] = '-1'; + } + } + + } else { + $responses = explode(',', $responses); + foreach ($this->order as $key => $ansid) { + if (in_array($ansid, $responses)) { + $data['choice' . $key] = 1; + } else { + $data['choice' . $key] = 0; + } + } + } + } +} + + +class qtype_shortanswer_updater extends qtype_updater { + public function right_answer() { + foreach ($this->question->options->answers as $ans) { + if ($ans->fraction > 0.999) { + return $ans->answer; + } + } + } + + public function was_answered($state) { + return !empty($state->answer); + } + + public function response_summary($state) { + if (!empty($state->answer)) { + return $state->answer; + } else { + return null; + } + } + + public function set_first_step_data_elements($state, &$data) { + } + + public function supply_missing_first_step_data(&$data) { + } + + public function set_data_elements_for_step($state, &$data) { + if (!empty($state->answer)) { + $data['answer'] = $state->answer; + } + } +} + + +class qtype_essay_updater extends qtype_updater { + public function right_answer() { + return ''; + } + + public function response_summary($state) { + if (!empty($state->answer)) { + return $this->to_text($state->answer); + } else { + return null; + } + } + + public function was_answered($state) { + return !empty($state->answer); + } + + public function set_first_step_data_elements($state, &$data) { + } + + public function supply_missing_first_step_data(&$data) { + } + + public function set_data_elements_for_step($state, &$data) { + if (!empty($state->answer)) { + $data['answer'] = $state->answer; + } + } +} + + +class qtype_numerical_updater extends qtype_updater { + public function right_answer() { + foreach ($this->question->options->answers as $ans) { + if ($ans->fraction > 0.999) { + return $ans->answer; + } + } + } + + public function response_summary($state) { + if (!empty($state->answer)) { + return $state->answer; + } else { + return null; + } + } + + public function was_answered($state) { + return !empty($state->answer); + } + + public function set_first_step_data_elements($state, &$data) { + $data['_separators'] = '.$,'; + } + + public function supply_missing_first_step_data(&$data) { + $data['_separators'] = '.$,'; + } + + public function set_data_elements_for_step($state, &$data) { + if (!empty($state->answer)) { + $data['answer'] = $state->answer; + } + } +} + + +class qtype_description_updater extends qtype_updater { + public function right_answer() { + return ''; + } + + public function was_answered($state) { + return false; + } + + public function response_summary($state) { + return ''; + } + + public function set_first_step_data_elements($state, &$data) { + } + + public function supply_missing_first_step_data(&$data) { + } + + public function set_data_elements_for_step($state, &$data) { + } +} + + +class qtype_truefalse_updater extends qtype_updater { + public function right_answer() { + foreach ($this->question->options->answers as $ans) { + if ($ans->fraction > 0.999) { + return $ans->answer; + } + } + } + + public function response_summary($state) { + if (is_numeric($state->answer)) { + if (array_key_exists($state->answer, $this->question->options->answers)) { + return $this->question->options->answers[$state->answer]->answer; + } else { + $this->logger->log_assumption("Dealing with a place where the + student selected a choice that was later deleted for + true/false question {$this->question->id}"); + return null; + } + } else { + return null; + } + } + + public function was_answered($state) { + return !empty($state->answer); + } + + public function set_first_step_data_elements($state, &$data) { + } + + public function supply_missing_first_step_data(&$data) { + } + + public function set_data_elements_for_step($state, &$data) { + if (is_numeric($state->answer)) { + $data['answer'] = (int) ($state->answer == $this->question->options->trueanswer); + } + } +} + + +class qtype_match_updater extends qtype_updater { + protected $stems; + protected $choices; + protected $right; + protected $stemorder; + protected $choiceorder; + protected $flippedchoiceorder; + + public function question_summary() { + $this->stems = array(); + $this->choices = array(); + $this->right = array(); + + foreach ($this->question->options->subquestions as $matchsub) { + $ans = $matchsub->answertext; + $key = array_search($matchsub->answertext, $this->choices); + if ($key === false) { + $key = $matchsub->id; + $this->choices[$key] = $matchsub->answertext; + } + + if ($matchsub->questiontext !== '') { + $this->stems[$matchsub->id] = $this->to_text($matchsub->questiontext); + $this->right[$matchsub->id] = $key; + } + } + + return $this->to_text($this->question->questiontext) . ' {' . + implode('; ', $this->stems) . '} -> {' . implode('; ', $this->choices) . '}'; + } + + public function right_answer() { + $answer = array(); + foreach ($this->stems as $key => $stem) { + $answer[$stem] = $this->choices[$this->right[$key]]; + } + return $this->make_summary($answer); + } + + protected function explode_answer($answer) { + if (!$answer) { + return array(); + } + $bits = explode(',', $answer); + $selections = array(); + foreach ($bits as $bit) { + list($stem, $choice) = explode('-', $bit); + $selections[$stem] = $choice; + } + return $selections; + } + + protected function make_summary($pairs) { + $bits = array(); + foreach ($pairs as $stem => $answer) { + $bits[] = $stem . ' -> ' . $answer; + } + return implode('; ', $bits); + } + + protected function lookup_choice($choice) { + foreach ($this->question->options->subquestions as $matchsub) { + if ($matchsub->code == $choice) { + if (array_key_exists($matchsub->id, $this->choices)) { + return $matchsub->id; + } else { + return array_search($matchsub->answertext, $this->choices); + } + } + } + return null; + } + + public function response_summary($state) { + $choices = $this->explode_answer($state->answer); + if (empty($choices)) { + return null; + } + + $pairs = array(); + foreach ($choices as $stemid => $choicekey) { + if (array_key_exists($stemid, $this->stems) && $choices[$stemid]) { + $choiceid = $this->lookup_choice($choicekey); + if ($choiceid) { + $pairs[$this->stems[$stemid]] = $this->choices[$choiceid]; + } else { + $this->logger->log_assumption("Dealing with a place where the + student selected a choice that was later deleted for + match question {$this->question->id}"); + $pairs[$this->stems[$stemid]] = '[CHOICE THAT WAS LATER DELETED]'; + } + } + } + + if ($pairs) { + return $this->make_summary($pairs); + } else { + return ''; + } + } + + public function was_answered($state) { + $choices = $this->explode_answer($state->answer); + foreach ($choices as $choice) { + if ($choice) { + return true; + } + } + return false; + } + + public function set_first_step_data_elements($state, &$data) { + $choices = $this->explode_answer($state->answer); + foreach ($choices as $key => $notused) { + if (array_key_exists($key, $this->stems)) { + $this->stemorder[] = $key; + } + } + + $this->choiceorder = array_keys($this->choices); + shuffle($this->choiceorder); + $this->flippedchoiceorder = array_combine(array_values($this->choiceorder), array_keys($this->choiceorder)); + + $data['_stemorder'] = implode(',', $this->stemorder); + $data['_choiceorder'] = implode(',', $this->choiceorder); + } + + public function supply_missing_first_step_data(&$data) { + throw new coding_exception('qtype_match_updater::supply_missing_first_step_data not tested'); + $data['_stemorder'] = array_keys($this->stems); + $data['_choiceorder'] = shuffle(array_keys($this->choices)); + } + + public function set_data_elements_for_step($state, &$data) { + $choices = $this->explode_answer($state->answer); + + foreach ($this->stemorder as $i => $key) { + if (empty($choices[$key])) { + $data['sub' . $i] = 0; + continue; + } + $choice = $this->lookup_choice($choices[$key]); + + if (array_key_exists($choice, $this->flippedchoiceorder)) { + $data['sub' . $i] = $this->flippedchoiceorder[$choice] + 1; + } else { + $data['sub' . $i] = 0; + } + } + } +} + + +class qtype_oumultiresponse_updater extends qtype_updater { + public function question_summary() { + $bits = array(); + foreach ($this->question->options->answers as $ans) { + $bits[] = $this->to_text($ans->answer); + } + return parent::question_summary() . ': ' . implode('; ', $bits); + } + + public function right_answer() { + $rightbits = array(); + foreach ($this->question->options->answers as $ans) { + if ($ans->fraction >= 0.5) { + $rightbits[] = $this->to_text($ans->answer); + } + } + return implode('; ', $rightbits); + } + + protected function parse_response($answer) { + if (strpos($answer, ':') === false) { + $this->logger->log_assumption("Dealing with missing order information + in attempt at oumultiresponse question {$this->question->id}"); + return array(null, $responses); + } + + list($order, $responsepart) = explode(':', $answer); + $bits = explode(',', $responsepart); + + $responses = array(); + if ($responsepart) { + foreach ($bits as $bit) { + if (strpos($bit, 'h')) { + list($choice, $history) = explode('h', $bit); + if (substr($history, -1) === '1') { + $responses[] = $choice; + } + } else { + // Very old code did this. + list($choice, $grade) = explode('g', $bit); + if ($grade > 0) { + $responses[] = $choice; + } + } + } + } + + return array($order, $responses); + } + + public function response_summary($state) { + list($order, $responses) = $this->parse_response($state->answer); + + $bits = array(); + foreach ($responses as $response) { + $bits[] = $this->to_text($this->question->options->answers[$response]->answer); + } + return implode('; ', $bits); + } + + public function was_answered($state) { + list($order, $responses) = explode(':', $state->answer); + return !empty($responses); + } + + public function set_first_step_data_elements($state, &$data) { + list($order, $responses) = $this->parse_response($state->answer); + $data['_order'] = $order; + } + + public function supply_missing_first_step_data(&$data) { + throw new coding_exception('qtype_oumultiresponse_updater::supply_missing_first_step_data not tested'); + $data['_order'] = implode(',', array_keys($this->question->option->answers)); + } + + public function set_data_elements_for_step($state, &$data) { + list($order, $responses) = $this->parse_response($state->answer); + $order = explode(',', $order); + + foreach ($order as $key => $ans) { + if (in_array($ans, $responses)) { + $data['choice' . $key] = 1; + } else { + $data['choice' . $key] = 0; + } + } + } +} + + +class qtype_ddwtos_updater extends qtype_updater { + protected $choices; + protected $rightchoices; + protected $places; + protected $choiceindexmap; + protected $shuffleorders; + + public function question_summary() { + $this->choices = array(); + $choiceindexmap = array(); + + // Store the choices in arrays by group. + $i = 1; + foreach ($this->question->options->answers as $choicedata) { + $options = unserialize($choicedata->feedback); + + if (array_key_exists($options->draggroup, $this->choices)) { + $this->choices[$options->draggroup][] = $choicedata->answer; + } else { + $this->choices[$options->draggroup][1] = $choicedata->answer; + } + + end($this->choices[$options->draggroup]); + $this->choiceindexmap[$i] = array($options->draggroup, $choicedata->answer, + key($this->choices[$options->draggroup])); + $i += 1; + } + + $this->places = array(); + $this->rightchoices = array(); + + // Break up the question text, and store the fragments, places and right answers. + $bits = preg_split('/\[\[(\d+)]]/', $this->question->questiontext, null, PREG_SPLIT_DELIM_CAPTURE); + array_shift($bits); + $i = 1; + + while (!empty($bits)) { + $choice = array_shift($bits); + + list($group, $choicetext, $choiceindex) = $this->choiceindexmap[$choice]; + $this->places[$i] = $group; + $this->rightchoices[$i] = $choicetext; + + array_shift($bits); + $i += 1; + } + + $bits = array(parent::question_summary()); + foreach ($this->places as $place => $group) { + $bits[] = '[[' . $place . ']] -> {' . + implode(' / ', $this->choices[$group]) . '}'; + } + return implode('; ', $bits); + } + + public function right_answer() { + return $this->make_summary($this->rightchoices); + } + + public function compare_answers($answer1, $answer2) { + list($answer1) = explode('=', $answer1); + list($answer2) = explode('=', $answer2); + return $answer1 == $answer2; + } + + protected function explode_answer($answer) { + list($answer) = explode('=', $answer); + if (!$answer) { + return array(); + } + + $bits = explode(';', $answer); + + $selections = array(); + foreach ($bits as $bit) { + list($place, $choice) = explode('-', $bit); + if ($place === '' || $choice === '0') { + continue; + } + + $selections[$place + 1] = $choice; + } + + return $selections; + } + + protected function make_summary($choices) { + $answers = array(); + $allblank = true; + foreach ($choices as $group => $ans) { + $answers[] = '{' . $ans . '}'; + $allblank = $allblank && ($ans === ''); + } + if ($allblank) { + return ''; + } else { + return implode(' ', $answers); + } + } + + public function response_summary($state) { + $choices = $this->explode_answer($state->answer); + + $answers = array(); + $allblank = true; + foreach ($this->places as $place => $group) { + if (array_key_exists($place, $choices) && $choices[$place]) { + list($notused, $choicetext, $choiceindex) = + $this->choiceindexmap[$choices[$place]]; + $answers[$place] = $choicetext; + } else { + $answers[$place] = ''; + } + } + return $this->make_summary($answers); + } + + public function was_answered($state) { + $choices = $this->explode_answer($state->answer); + foreach ($choices as $choice) { + if ($choice) { + return true; + } + } + return false; + } + + public function set_first_step_data_elements($state, &$data) { + foreach ($this->choices as $group => $notused) { + $this->shuffleorders[$group] = array_keys($this->choices[$group]); + if ($this->question->options->shuffleanswers) { + srand($state->attempt); + shuffle($this->shuffleorders[$group]); + } + $this->shuffleorders[$group] = array_combine( + array_values($this->shuffleorders[$group]), array_keys($this->shuffleorders[$group])); + } + + foreach ($this->choices as $group => $choices) { + $indices = array(); + foreach ($this->shuffleorders[$group] as $key => $notused) { + $indices[] = $key; + } + $data['_choiceorder' . $group] = implode(',', $indices); + } + } + + public function supply_missing_first_step_data(&$data) { + throw new coding_exception('qtype_ddwtos_updater::supply_missing_first_step_data not implemented'); + } + + public function set_data_elements_for_step($state, &$data) { + $choices = $this->explode_answer($state->answer); + + foreach ($this->places as $place => $group) { + if (array_key_exists($place, $choices) && + array_key_exists($choices[$place], $this->choiceindexmap)) { + list($notused, $choicetext, $choiceindex) = + $this->choiceindexmap[$choices[$place]]; + $data['p' . $place] = $this->shuffleorders[$this->places[$place]][$choiceindex] + 1; + } else { + $data['p' . $place] = 0; + } + } + } +} + + +class qtype_opaque_updater extends qtype_updater { + public function question_summary() { + return $this->question->options->remoteid . '.' . $this->question->options->remoteversion; + } + + public function right_answer() { + return '[UNKNOWN]'; + } + + protected function explode_answer($answer) { + // We store the reponses by turning the associative array $state->responses + // into a string as follows. For example, array('f2' => 'No, never - ever', 'f1' => '10') + // becomes 'f1-10,f2-No\, never - ever'. That is, comma separated pairs, sorted by key, + // key and value linked with a '-', commas in vales escaped with '\'. + + // Deal with special case: no responses at all. + if (empty($answer)) { + return array(); + } + + // Split the responses on non-backslash-escaped commas. + $bits = preg_split('/(?responses properly. + $responses = array(); + foreach ($bits as $reponse) { + list($key, $value) = explode('-', $reponse, 2); + $responses[$key] = str_replace('\,', ',', $value); + } + + return $responses; + } + + public function response_summary($state) { + $responses = $this->explode_answer($state->answer); + + if (!empty($responses['__answerLine'])) { + return $responses['__answerLine']; + + } else if (!empty($responses['__actionSummary'])) { + return $responses['__actionSummary']; + + } else { + return implode(', ', $responses); + } + } + + public function was_answered($state) { + return false; + } + + public function set_first_step_data_elements($state, &$data) { + $responses = $this->explode_answer($state->answer); + foreach ($responses as $name => $value) { + if ($name == '__randomseed') { + $data['-_randomseed'] = $value; + } else { + $data[$name] = $value; + } + } + } + + public function supply_missing_first_step_data(&$data) { + $data['-_randomseed'] = rand(); + } + + public function set_data_elements_for_step($state, &$data) { + $responses = $this->explode_answer($state->answer); + foreach ($responses as $name => $value) { + if ($name == '__questionLine') { + continue; + } else if ($name == '__actionSummary') { + $name = '-_actionSummary'; + } + $data[$name] = $value; + } + } +} + +class qtype_deleted_updater extends qtype_updater { + public function right_answer() { + return ''; + } + + public function response_summary($state) { + return $state->answer; + } + + public function was_answered($state) { + return !empty($state->answer); + } + + public function set_first_step_data_elements($state, &$data) { + $data['upgradedfromdeletedquestion'] = $state->answer; + } + + public function supply_missing_first_step_data(&$data) { + } + + public function set_data_elements_for_step($state, &$data) { + $data['upgradedfromdeletedquestion'] = $state->answer; + } +} diff --git a/question/file.php b/question/file.php index b6ea0470473..ac9a133faed 100644 --- a/question/file.php +++ b/question/file.php @@ -1,86 +1,111 @@ . + + +/** + * This script fetches files from the dataroot/questionattempt directory + * It is based on the top-level file.php + * + * On a module-by-module basis (currently only implemented for quiz), it checks + * whether the user has permission to view the file. + * + * Syntax: question/file.php/attemptid/questionid/filename.ext + * Workaround: question/file.php?file=/attemptid/questionid/filename.ext + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2007 Adriane Boyd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + // disable moodle specific debug messages and any errors in output define('NO_DEBUG_DISPLAY', true); - require_once('../config.php'); - require_once('../lib/filelib.php'); - $relativepath = get_file_argument(); - // force download for any student-submitted files to prevent XSS attacks. - $forcedownload = 1; +require_once('../config.php'); +require_once('../lib/filelib.php'); - // relative path must start with '/', because of backup/restore!!! - if (!$relativepath) { - print_error('invalidarguments'); - } else if ($relativepath{0} != '/') { - print_error('pathdoesnotstartslash'); +$relativepath = get_file_argument(); +// force download for any student-submitted files to prevent XSS attacks. +$forcedownload = 1; + +// relative path must start with '/', because of backup/restore!!! +if (!$relativepath) { + print_error('invalidarguments'); +} else if ($relativepath{0} != '/') { + print_error('pathdoesnotstartslash'); +} + +$pathname = $CFG->dataroot.'/questionattempt'.$relativepath; + +// extract relative path components +$args = explode('/', trim($relativepath, '/')); + +// check for the right number of directories in the path +if (count($args) != 3) { + print_error('invalidarguments'); +} + +// security: require login +require_login(); + +// security: do not return directory node! +if (is_dir($pathname)) { + question_attempt_not_found(); +} + +$lifetime = 0; // do not cache because students may reupload files + +// security: check that the user has permission to access this file +$haspermission = false; +if ($attempt = $DB->get_record("question_attempts", array("id" => $args[0]))) { + $modfile = $CFG->dirroot .'/mod/'. $attempt->modulename .'/lib.php'; + $modcheckfileaccess = $attempt->modulename .'_check_file_access'; + if (file_exists($modfile)) { + @require_once($modfile); + if (function_exists($modcheckfileaccess)) { + $haspermission = $modcheckfileaccess($args[0], $args[1]); + } } - - $pathname = $CFG->dataroot.'/questionattempt'.$relativepath; - - // extract relative path components - $args = explode('/', trim($relativepath, '/')); - - // check for the right number of directories in the path - if (count($args) != 3) { - print_error('invalidarguments'); +} else if ($args[0][0] == 0) { + global $USER; + $list = explode('_', $args[0]); + if ($list[1] == $USER->id) { + $haspermission = true; } +} - // security: require login - require_login(); - - // security: do not return directory node! - if (is_dir($pathname)) { +if ($haspermission) { + // check that file exists + if (!file_exists($pathname)) { question_attempt_not_found(); } - $lifetime = 0; // do not cache because students may reupload files - - // security: check that the user has permission to access this file - $haspermission = false; - if ($attempt = $DB->get_record("question_attempts", array("id" => $args[0]))) { - $modfile = $CFG->dirroot .'/mod/'. $attempt->modulename .'/lib.php'; - $modcheckfileaccess = $attempt->modulename .'_check_file_access'; - if (file_exists($modfile)) { - @require_once($modfile); - if (function_exists($modcheckfileaccess)) { - $haspermission = $modcheckfileaccess($args[0], $args[1]); - } - } - } else if ($args[0][0] == 0) { - global $USER; - $list = explode('_', $args[0]); - if ($list[1] == $USER->id) { - $haspermission = true; - } - } - - if ($haspermission) { - // check that file exists - if (!file_exists($pathname)) { - question_attempt_not_found(); - } - - // send the file - session_get_instance()->write_close(); // unlock session during fileserving - $filename = $args[count($args)-1]; - send_file($pathname, $filename, $lifetime, $CFG->filteruploadedfiles, false, $forcedownload); - } else { - question_attempt_not_found(); - } - - function question_attempt_not_found() { - global $CFG; - header('HTTP/1.0 404 not found'); - print_error('filenotfound', 'error', $CFG->wwwroot); //this is not displayed on IIS?? - } + // send the file + session_get_instance()->write_close(); // unlock session during fileserving + $filename = $args[count($args)-1]; + send_file($pathname, $filename, $lifetime, $CFG->filteruploadedfiles, false, $forcedownload); +} else { + question_attempt_not_found(); +} +function question_attempt_not_found() { + global $CFG; + header('HTTP/1.0 404 not found'); + print_error('filenotfound', 'error', $CFG->wwwroot); //this is not displayed on IIS?? +} diff --git a/question/format/examview/format.php b/question/format/examview/format.php old mode 100755 new mode 100644 diff --git a/question/format/gift/format.php b/question/format/gift/format.php old mode 100755 new mode 100644 diff --git a/question/format/learnwise/format.php b/question/format/learnwise/format.php old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/choice.tpl b/question/format/qti_two/templates/choice.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/choiceMultiple.tpl b/question/format/qti_two/templates/choiceMultiple.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/composite.tpl b/question/format/qti_two/templates/composite.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/extendedText.tpl b/question/format/qti_two/templates/extendedText.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/extendedText_simpleEssay.tpl b/question/format/qti_two/templates/extendedText_simpleEssay.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/imsmanifest.tpl b/question/format/qti_two/templates/imsmanifest.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/mmchoiceMultiple.tpl b/question/format/qti_two/templates/mmchoiceMultiple.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/notimplemented.tpl b/question/format/qti_two/templates/notimplemented.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/numerical.tpl b/question/format/qti_two/templates/numerical.tpl old mode 100755 new mode 100644 diff --git a/question/format/qti_two/templates/textEntry.tpl b/question/format/qti_two/templates/textEntry.tpl old mode 100755 new mode 100644 diff --git a/question/format/xhtml/format.php b/question/format/xhtml/format.php old mode 100755 new mode 100644 diff --git a/question/format/xml/format.php b/question/format/xml/format.php old mode 100755 new mode 100644 diff --git a/question/import_form.php b/question/import_form.php index f585e29e51e..30408a655c5 100644 --- a/question/import_form.php +++ b/question/import_form.php @@ -78,5 +78,23 @@ class question_import_form extends moodleform { $template = "{help} {element}\n"; $renderer->setGroupElementTemplate($template, 'format'); } + + public function validation($data, $files){ + $errors = parent::validation($data, $files); + return $errors; // TODO. + // If a file is uploaded return + if ($data['choosefile'] || $files['newfile']) { + return $errors; + } + // If file is not uploaded from file upload + if (!$files['newfile']) { + $errors['newfile'] = get_string('importfromupload', 'question'); + } + // If filke is not imported from course files + if (!$data['choosefile'] && !$files['newfile']) { + $errors['choosefile'] = get_string('importfromcoursefiles', 'question'); + } + return $errors; + } } diff --git a/question/move_form.php b/question/move_form.php index e5b6a4afcc5..f173004bba9 100644 --- a/question/move_form.php +++ b/question/move_form.php @@ -1,5 +1,30 @@ . + + +/** + * Form for moving questions between categories. + * + * @package moodlecore + * @subpackage questionbank + * @copyright 2008 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); /// It must be included from a Moodle page } @@ -7,22 +32,17 @@ if (!defined('MOODLE_INTERNAL')) { require_once($CFG->libdir.'/formslib.php'); class question_move_form extends moodleform { - function definition() { - $mform =& $this->_form; + $mform = $this->_form; - $currentcat = $this->_customdata['currentcat']; - $contexts = $this->_customdata['contexts']; -//-------------------------------------------------------------------------------- + $currentcat = $this->_customdata['currentcat']; + $contexts = $this->_customdata['contexts']; $mform->addElement('questioncategory', 'category', get_string('category','quiz'), compact('contexts', 'currentcat')); - -//-------------------------------------------------------------------------------- $this->add_action_buttons(true, get_string('categorymoveto', 'quiz')); -//-------------------------------------------------------------------------------- + $mform->addElement('hidden', 'delete', $currentcat); $mform->setType('delete', PARAM_INT); } } - diff --git a/question/preview.js b/question/preview.js new file mode 100644 index 00000000000..c502d8d653b --- /dev/null +++ b/question/preview.js @@ -0,0 +1,46 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + + +/** + * This file 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 + */ + + +/** + * Initialise JavaScript-specific parts of the question preview popup. + */ +function question_preview_init(caption, addto) { + // Add a close button to the window. + var button = document.createElement('input'); + button.type = 'button'; + button.value = caption; + + YAHOO.util.Event.addListener(button, 'click', function() { window.close() }); + + var container = document.getElementById(addto); + container.appendChild(button); + + // Make changint the settings disable all submit buttons, like clicking one of the + // question buttons does. + var form = document.getElementById('mform1'); + YAHOO.util.Event.addListener(form, 'submit', + question_prevent_repeat_submission, document.body); +} \ No newline at end of file diff --git a/question/type/calculated/pix/icon.gif b/question/type/calculated/pix/icon.gif old mode 100755 new mode 100644 diff --git a/question/type/description/old_questiontype.php b/question/type/description/old_questiontype.php new file mode 100644 index 00000000000..a0ae412637e --- /dev/null +++ b/question/type/description/old_questiontype.php @@ -0,0 +1,92 @@ +defaultgrade = 0; + return parent::save_question($question, $form); + } + + function get_question_options(&$question) { + return true; + } + + function save_question_options($question) { + return true; + } + + function print_question(&$question, &$state, $number, $cmoptions, $options) { + global $CFG; + $isfinished = question_state_is_graded($state->last_graded) || $state->event == QUESTION_EVENTCLOSE; + + // For editing teachers print a link to an editing popup window + $editlink = $this->get_question_edit_link($question, $cmoptions, $options); + + $questiontext = $this->format_text($question->questiontext, $question->questiontextformat, $cmoptions); + + $generalfeedback = ''; + if ($isfinished && $options->generalfeedback) { + $generalfeedback = $this->format_text($question->generalfeedback, + $question->generalfeedbackformat, $cmoptions); + } + + include "$CFG->dirroot/question/type/description/question.html"; + } + + function actual_number_of_questions($question) { + return 0; + } + + function grade_responses(&$question, &$state, $cmoptions) { + $state->raw_grade = 0; + $state->penalty = 0; + return true; + } +} +// Register this question type with questionlib.php. +question_register_questiontype(new description_qtype()); diff --git a/question/type/description/question.php b/question/type/description/question.php new file mode 100644 index 00000000000..eb5fd12a165 --- /dev/null +++ b/question/type/description/question.php @@ -0,0 +1,35 @@ +. + + +/** + * Description 'question' definition class. + * + * @package qtype_description + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Represents a description 'question'. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_description_question extends question_information_item { +} diff --git a/question/type/description/questiontype.php b/question/type/description/questiontype.php index 26b09feab95..b79a547a73a 100644 --- a/question/type/description/questiontype.php +++ b/question/type/description/questiontype.php @@ -1,95 +1,73 @@ . + /** - * The description question type is not acutally a question, it is just a way - * to add some static content in the middle of a quiz, or other place that - * questions are used. + * Question type class for the description 'question' type. * - * @package questionbank - * @subpackage questiontypes + * @package qtype_description + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class description_qtype extends default_questiontype { - function name() { - return 'description'; - } +require_once($CFG->libdir . '/questionlib.php'); - function is_real_question_type() { +/** + * The description 'question' type. + * + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_description extends question_type { + public function is_usable_by_random() { return false; } - function is_usable_by_random() { + public function can_analyse_responses() { return false; } - function save_question($question, $form) { + public function save_question($question, $form, $course) { // Make very sure that descriptions can'e be created with a grade of // anything other than 0. - $form->defaultgrade = 0; - return parent::save_question($question, $form); + $form->defaultmark = 0; + return parent::save_question($question, $form, $course); } - function get_question_options(&$question) { + public function get_question_options(&$question) { + // No options to be restored for this question type return true; } - function save_question_options($question) { + public function save_question_options($question) { + /// No options to be saved for this question type: return true; } - function print_question(&$question, &$state, $number, $cmoptions, $options) { - global $CFG; - $isfinished = question_state_is_graded($state->last_graded) || $state->event == QUESTION_EVENTCLOSE; - - // For editing teachers print a link to an editing popup window - $editlink = $this->get_question_edit_link($question, $cmoptions, $options); - - $context = $this->get_context_by_category_id($question->category); - $question->questiontext = quiz_rewrite_question_urls($question->questiontext, 'pluginfile.php', $context->id, 'question', 'questiontext', array($state->attempt, $state->question), $question->id); - $questiontext = $this->format_text($question->questiontext, $question->questiontextformat, $cmoptions); - - $generalfeedback = ''; - if ($isfinished && $options->generalfeedback) { - $question->generalfeedback = quiz_rewrite_question_urls($question->generalfeedback, 'pluginfile.php', $context->id, 'question', 'generalfeedback', array($state->attempt, $state->question), $question->id); - $generalfeedback = $this->format_text($question->generalfeedback, - $question->generalfeedbackformat, $cmoptions); - } - - include "$CFG->dirroot/question/type/description/question.html"; - } - - function actual_number_of_questions($question) { + public function actual_number_of_questions($question) { + /// Used for the feature number-of-questions-per-page + /// to determine the actual number of questions wrapped + /// by this question. + /// The question type description is not even a question + /// in itself so it will return ZERO! return 0; } - function grade_responses(&$question, &$state, $cmoptions) { - $state->raw_grade = 0; - $state->penalty = 0; - return true; + public function get_random_guess_score($questiondata) { + return null; } } -// Register this question type with questionlib.php. -question_register_questiontype(new description_qtype()); diff --git a/question/type/description/renderer.php b/question/type/description/renderer.php new file mode 100644 index 00000000000..70eb9933f4e --- /dev/null +++ b/question/type/description/renderer.php @@ -0,0 +1,41 @@ +. + + +/** + * Description 'question' renderer class. + * + * @package qtype_description + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Generates the output for description 'question's. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_description_renderer extends qtype_renderer { + public function formulation_and_controls(question_attempt $qa, + question_display_options $options) { + + return html_writer::tag('div', $qa->get_question()->format_questiontext(), + array('class' => 'qtext')); + } +} diff --git a/question/type/missingtype/old_questiontype.php b/question/type/missingtype/old_questiontype.php new file mode 100644 index 00000000000..b728f840b4b --- /dev/null +++ b/question/type/missingtype/old_questiontype.php @@ -0,0 +1,78 @@ +options->answers; + + $formatoptions = new stdClass; + $formatoptions->noclean = true; + $formatoptions->para = false; + + // Print formulation + $questiontext = format_text($question->questiontext, + $question->questiontextformat, + $formatoptions, $cmoptions->course); + + // Print each answer in a separate row if there are any + $anss = array(); + if ($answers) { + foreach ($answers as $answer) { + $a = new stdClass; + $a->text = format_text($answer->answer, $answer->answerformat, $formatoptions, $cmoptions->course); + + $anss[] = clone($a); + } + } + include("$CFG->dirroot/question/type/missingtype/display.html"); + } + + function grade_responses(&$question, &$state, $cmoptions) { + return true; + } + + function display_question_editing_page(&$mform, $question, $wizardnow){ + global $OUTPUT; + echo $OUTPUT->heading(get_string('warningmissingtype', 'qtype_missingtype')); + + $mform->display(); + + } +} +//// END OF CLASS //// + +////////////////////////////////////////////////////////////////////////// +//// INITIATION - Without this line the question type is not in use... /// +////////////////////////////////////////////////////////////////////////// +question_register_questiontype(new question_missingtype_qtype()); + + diff --git a/question/type/missingtype/question.php b/question/type/missingtype/question.php new file mode 100644 index 00000000000..c0e6cf69919 --- /dev/null +++ b/question/type/missingtype/question.php @@ -0,0 +1,86 @@ +. + + +/** + * Defines the 'qtype_missingtype' question definition class. + * + * @package qtype_missingtype + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * This question definition class is used when the actual question type of this + * question cannot be found. + * + * Why does this this class implement question_automatically_gradable? I am not + * sure at the moment. Perhaps it is important for it to work with as many + * behaviours as possible. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_missingtype_question extends question_definition implements question_automatically_gradable { + public function get_expected_data() { + return array(); + } + + public function get_correct_response() { + return array(); + } + + public function is_complete_response(array $response) { + return false; + } + + public function is_gradable_response(array $response) { + return false; + } + + public function get_validation_error(array $response) { + return ''; + } + + public function is_same_response(array $prevresponse, array $newresponse) { + return true; + } + + public function get_right_answer_summary() { + return ''; + } + + public function summarise_response(array $response) { + return null; + } + + public function classify_response(array $response) { + return array(); + } + + public function init_first_step(question_attempt_step $step) { + } + + public function grade_response(array $response) { + throw new Exception('This question is of a type that is not installed on your system. No processing is possible.'); + } + + public function get_hint($hintnumber, question_attempt $qa) { + return null; + } +} diff --git a/question/type/missingtype/questiontype.php b/question/type/missingtype/questiontype.php index b728f840b4b..d3a1af48643 100644 --- a/question/type/missingtype/questiontype.php +++ b/question/type/missingtype/questiontype.php @@ -1,78 +1,92 @@ . + + +/** + * Question type class for the 'missingtype' type. + * + * @package qtype_missingtype + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ -/// QUESTION TYPE CLASS ////////////////// /** * Missing question type class * - * When a question is encountered with a type that is not installed then its - * type is changed to 'missingtype'. This questiontype just makes sure that the - * necessary information is printed about the question. - * @package questionbank - * @subpackage questiontypes + * When we encounter a question of a type that is not currently installed, then + * we use this question type class instead so that some of the information about + * this question can be seen, and the rest of the system keeps working. + * + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class question_missingtype_qtype extends default_questiontype { - - function name() { - return 'missingtype'; - } - - function menu_name() { +class qtype_missingtype extends question_type { + public function menu_name() { return false; } - function is_usable_by_random() { + public function is_usable_by_random() { return false; } - function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) { - global $CFG; - - $answers = &$question->options->answers; - - $formatoptions = new stdClass; - $formatoptions->noclean = true; - $formatoptions->para = false; - - // Print formulation - $questiontext = format_text($question->questiontext, - $question->questiontextformat, - $formatoptions, $cmoptions->course); - - // Print each answer in a separate row if there are any - $anss = array(); - if ($answers) { - foreach ($answers as $answer) { - $a = new stdClass; - $a->text = format_text($answer->answer, $answer->answerformat, $formatoptions, $cmoptions->course); - - $anss[] = clone($a); - } - } - include("$CFG->dirroot/question/type/missingtype/display.html"); + public function can_analyse_responses() { + return false; } - function grade_responses(&$question, &$state, $cmoptions) { - return true; + public function make_question($questiondata) { + $question = parent::make_question($questiondata); + $question->questiontext = html_writer::tag('div', + get_string('missingqtypewarning', 'qtype_missingtype'), + array('class' => 'warning missingqtypewarning')) . + $question->questiontext; + return $question; } - function display_question_editing_page(&$mform, $question, $wizardnow){ - global $OUTPUT; - echo $OUTPUT->heading(get_string('warningmissingtype', 'qtype_missingtype')); + public function make_deleted_instance($questionid, $maxmark) { + question_bank::load_question_definition_classes('missingtype'); + $question = new qtype_missingtype_question(); + $question->id = $questionid; + $question->category = null; + $question->parent = 0; + $question->qtype = question_bank::get_qtype('missingtype'); + $question->name = get_string('deletedquestion', 'qtype_missingtype'); + $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype'); + $question->questiontextformat = FORMAT_HTML; + $question->generalfeedback = ''; + $question->defaultmark = $maxmark; + $question->length = 1; + $question->penalty = 0; + $question->stamp = ''; + $question->version = 0; + $question->hidden = 0; + $question->timecreated = null; + $question->timemodified = null; + $question->createdby = null; + $question->modifiedby = null; + return $question; + } + public function get_random_guess_score($questiondata) { + return null; + } + + public function display_question_editing_page($mform, $question, $wizardnow){ + print_heading(get_string('missingqtypewarning', 'qtype_missingtype')); $mform->display(); - } } -//// END OF CLASS //// - -////////////////////////////////////////////////////////////////////////// -//// INITIATION - Without this line the question type is not in use... /// -////////////////////////////////////////////////////////////////////////// -question_register_questiontype(new question_missingtype_qtype()); - - diff --git a/question/type/missingtype/renderer.php b/question/type/missingtype/renderer.php new file mode 100644 index 00000000000..d3b05f9186e --- /dev/null +++ b/question/type/missingtype/renderer.php @@ -0,0 +1,36 @@ +. + + +/** + * Defines the 'missingtype' question renderer class. + * + * @package qtype_missingtype + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * This question renderer class is used when the actual question type of this + * question cannot be found. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_missingtype_renderer extends qtype_renderer { +} diff --git a/question/type/old_questiontype.php b/question/type/old_questiontype.php new file mode 100644 index 00000000000..5aaf4d323d8 --- /dev/null +++ b/question/type/old_questiontype.php @@ -0,0 +1,1780 @@ +. + +/** + * The default questiontype class. + * + * @author Martin Dougiamas and many others. This has recently been completely + * rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of + * the Serving Mathematics project + * {@link http://maths.york.ac.uk/serving_maths} + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + * @package questionbank + * @subpackage questiontypes + */ + +require_once($CFG->libdir . '/questionlib.php'); + +/** + * This is the base class for Moodle question types. + * + * There are detailed comments on each method, explaining what the method is + * for, and the circumstances under which you might need to override it. + * + * Note: the questiontype API should NOT be considered stable yet. Very few + * question tyeps have been produced yet, so we do not yet know all the places + * where the current API is insufficient. I would rather learn from the + * experiences of the first few question type implementors, and improve the + * interface to meet their needs, rather the freeze the API prematurely and + * condem everyone to working round a clunky interface for ever afterwards. + * + * @package questionbank + * @subpackage questiontypes + */ +class default_questiontype { + protected $fileoptions = array( + 'subdirs' => false, + 'maxfiles' => -1, + 'maxbytes' => 0, + ); + + /** + * Name of the question type + * + * The name returned should coincide with the name of the directory + * in which this questiontype is located + * + * @return string the name of this question type. + */ + function name() { + return 'default'; + } + + /** + * Returns a list of other question types that this one requires in order to + * work. For example, the calculated question type is a subclass of the + * numerical question type, which is a subclass of the shortanswer question + * type; and the randomsamatch question type requires the shortanswer type + * to be installed. + * + * @return array any other question types that this one relies on. An empty + * array if none. + */ + function requires_qtypes() { + return array(); + } + + /** + * @return string the name of this pluginfor passing to get_string, set/get_config, etc. + */ + function plugin_name() { + return 'qtype_' . $this->name(); + } + + /** + * @return string the name of this question type in the user's language. + * You should not need to override this method, the default behaviour should be fine. + */ + function local_name() { + return get_string($this->name(), $this->plugin_name()); + } + + /** + * The name this question should appear as in the create new question + * dropdown. Override this method to return false if you don't want your + * question type to be createable, for example if it is an abstract base type, + * otherwise, you should not need to override this method. + * + * @return mixed the desired string, or false to hide this question type in the menu. + */ + function menu_name() { + return $this->local_name(); + } + + /** + * @return boolean override this to return false if this is not really a + * question type, for example the description question type is not + * really a question type. + */ + function is_real_question_type() { + return true; + } + + /** + * @return boolean true if this question type may require manual grading. + */ + function is_manual_graded() { + return false; + } + + /** + * @param object $question a question of this type. + * @param string $otherquestionsinuse comma-separate list of other question ids in this attempt. + * @return boolean true if a particular instance of this question requires manual grading. + */ + function is_question_manual_graded($question, $otherquestionsinuse) { + return $this->is_manual_graded(); + } + + /** + * @return boolean true if a table analyzing responses should be shown in + * the quiz statistics report. Usually if a question is manually graded + * then this analysis table won't be a good idea. + */ + function show_analysis_of_responses() { + return !$this->is_manual_graded(); + } + + /** + * @return boolean true if this question type can be used by the random question type. + */ + function is_usable_by_random() { + return true; + } + + /** + * @param question record. + * @param integer subqid this is the id of the subquestion. Usually the id + * of the question record of the question record but this is dependent on + * the question type. Not relevant to some question types. + * @return whether the teacher supplied responses can include wildcards. Can + * more than one answer be equivalent to one teacher supplied response. + */ + function has_wildcards_in_responses($question, $subqid) { + return false; + } + + /** + * If your question type has a table that extends the question table, and + * you want the base class to automatically save, backup and restore the extra fields, + * override this method to return an array wherer the first element is the table name, + * and the subsequent entries are the column names (apart from id and questionid). + * + * @return mixed array as above, or null to tell the base class to do nothing. + */ + function extra_question_fields() { + return null; + } + + /** + * If you use extra_question_fields, overload this function to return question id field name + * in case you table use another name for this column + */ + function questionid_column_name() { + return 'questionid'; + } + + /** + * If your question type has a table that extends the question_answers table, + * make this method return an array wherer the first element is the table name, + * and the subsequent entries are the column names (apart from id and answerid). + * + * @return mixed array as above, or null to tell the base class to do nothing. + */ + function extra_answer_fields() { + return null; + } + + /** + * Return an instance of the question editing form definition. This looks for a + * class called edit_{$this->name()}_question_form in the file + * {$CFG->dirroot}/question/type/{$this->name()}/edit_{$this->name()}_question_form.php + * and if it exists returns an instance of it. + * + * @param string $submiturl passed on to the constructor call. + * @return object an instance of the form definition, or null if one could not be found. + */ + function create_editing_form($submiturl, $question, $category, $contexts, $formeditable) { + global $CFG; + require_once("{$CFG->dirroot}/question/type/edit_question_form.php"); + $definition_file = $CFG->dirroot.'/question/type/'.$this->name().'/edit_'.$this->name().'_form.php'; + if (!(is_readable($definition_file) && is_file($definition_file))) { + return null; + } + require_once($definition_file); + $classname = 'question_edit_'.$this->name().'_form'; + if (!class_exists($classname)) { + return null; + } + return new $classname($submiturl, $question, $category, $contexts, $formeditable); + } + + /** + * @return string the full path of the folder this plugin's files live in. + */ + function plugin_dir() { + global $CFG; + return $CFG->dirroot . '/question/type/' . $this->name(); + } + + /** + * @return string the URL of the folder this plugin's files live in. + */ + function plugin_baseurl() { + global $CFG; + return $CFG->wwwroot . '/question/type/' . $this->name(); + } + + /** + * This method should be overriden if you want to include a special heading or some other + * html on a question editing page besides the question editing form. + * + * @param question_edit_form $mform a child of question_edit_form + * @param object $question + * @param string $wizardnow is '' for first page. + */ + function display_question_editing_page(&$mform, $question, $wizardnow){ + global $OUTPUT; + $heading = $this->get_heading(empty($question->id)); + + echo $OUTPUT->heading_with_help($heading, $this->name(), $this->plugin_name()); + + $permissionstrs = array(); + if (!empty($question->id)){ + if ($question->formoptions->canedit){ + $permissionstrs[] = get_string('permissionedit', 'question'); + } + if ($question->formoptions->canmove){ + $permissionstrs[] = get_string('permissionmove', 'question'); + } + if ($question->formoptions->cansaveasnew){ + $permissionstrs[] = get_string('permissionsaveasnew', 'question'); + } + } + if (!$question->formoptions->movecontext && count($permissionstrs)){ + echo $OUTPUT->heading(get_string('permissionto', 'question'), 3); + $html = '
    '; + foreach ($permissionstrs as $permissionstr){ + $html .= '
  • '.$permissionstr.'
  • '; + } + $html .= '
'; + echo $OUTPUT->box($html, 'boxwidthnarrow boxaligncenter generalbox'); + } + $mform->display(); + } + + /** + * Method called by display_question_editing_page and by question.php to get heading for breadcrumbs. + * + * @return array a string heading and the langmodule in which it was found. + */ + function get_heading($adding = false){ + if ($adding) { + $prefix = 'adding'; + } else { + $prefix = 'editing'; + } + return get_string($prefix . $this->name(), $this->plugin_name()); + } + + /** + * Saves (creates or updates) a question. + * + * Given some question info and some data about the answers + * this function parses, organises and saves the question + * It is used by {@link question.php} when saving new data from + * a form, and also by {@link import.php} when importing questions + * This function in turn calls {@link save_question_options} + * to save question-type specific data. + * + * Whether we are saving a new question or updating an existing one can be + * determined by testing !empty($question->id). If it is not empty, we are updating. + * + * The question will be saved in category $form->category. + * + * @param object $question the question object which should be updated. For a new question will be mostly empty. + * @param object $form the object containing the information to save, as if from the question editing form. + * @param object $course not really used any more. + * @return object On success, return the new question object. On failure, + * return an object as follows. If the error object has an errors field, + * display that as an error message. Otherwise, the editing form will be + * redisplayed with validation errors, from validation_errors field, which + * is itself an object, shown next to the form fields. (I don't think this is accurate any more.) + */ + function save_question($question, $form) { + global $USER, $DB, $OUTPUT; + + list($question->category) = explode(',', $form->category); + $context = $this->get_context_by_category_id($question->category); + + // This default implementation is suitable for most + // question types. + + // First, save the basic question itself + $question->name = trim($form->name); + $question->parent = isset($form->parent) ? $form->parent : 0; + $question->length = $this->actual_number_of_questions($question); + $question->penalty = isset($form->penalty) ? $form->penalty : 0; + + if (empty($form->questiontext['text'])) { + $question->questiontext = ''; + } else { + $question->questiontext = trim($form->questiontext['text']);; + } + $question->questiontextformat = !empty($form->questiontext['format'])?$form->questiontext['format']:0; + + if (empty($form->generalfeedback['text'])) { + $question->generalfeedback = ''; + } else { + $question->generalfeedback = trim($form->generalfeedback['text']); + } + $question->generalfeedbackformat = !empty($form->generalfeedback['format'])?$form->generalfeedback['format']:0; + + if (empty($question->name)) { + $question->name = shorten_text(strip_tags($form->questiontext['text']), 15); + if (empty($question->name)) { + $question->name = '-'; + } + } + + if ($question->penalty > 1 or $question->penalty < 0) { + $question->errors['penalty'] = get_string('invalidpenalty', 'quiz'); + } + + if (isset($form->defaultgrade)) { + $question->defaultgrade = $form->defaultgrade; + } + + // If the question is new, create it. + if (empty($question->id)) { + // Set the unique code + $question->stamp = make_unique_id_code(); + $question->createdby = $USER->id; + $question->timecreated = time(); + $question->id = $DB->insert_record('question', $question); + } + + // Now, whether we are updating a existing question, or creating a new + // one, we have to do the files processing and update the record. + /// Question already exists, update. + $question->modifiedby = $USER->id; + $question->timemodified = time(); + + if (!empty($question->questiontext) && !empty($form->questiontext['itemid'])) { + $question->questiontext = file_save_draft_area_files($form->questiontext['itemid'], $context->id, 'question', 'questiontext', (int)$question->id, $this->fileoptions, $question->questiontext); + } + if (!empty($question->generalfeedback) && !empty($form->generalfeedback['itemid'])) { + $question->generalfeedback = file_save_draft_area_files($form->generalfeedback['itemid'], $context->id, 'question', 'generalfeedback', (int)$question->id, $this->fileoptions, $question->generalfeedback); + } + $DB->update_record('question', $question); + + // Now to save all the answers and type-specific options + $form->id = $question->id; + $form->qtype = $question->qtype; + $form->category = $question->category; + $form->questiontext = $question->questiontext; + $form->questiontextformat = $question->questiontextformat; + // current context + $form->context = $context; + + $result = $this->save_question_options($form); + + if (!empty($result->error)) { + print_error($result->error); + } + + if (!empty($result->notice)) { + notice($result->notice, "question.php?id=$question->id"); + } + + if (!empty($result->noticeyesno)) { + throw new coding_exception('$result->noticeyesno no longer supported in save_question.'); + } + + // Give the question a unique version stamp determined by question_hash() + $DB->set_field('question', 'version', question_hash($question), array('id' => $question->id)); + + return $question; + } + + /** + * Saves question-type specific options + * + * This is called by {@link save_question()} to save the question-type specific data + * @return object $result->error or $result->noticeyesno or $result->notice + * @param object $question This holds the information from the editing form, + * it is not a standard question object. + */ + function save_question_options($question) { + global $DB; + $extra_question_fields = $this->extra_question_fields(); + + if (is_array($extra_question_fields)) { + $question_extension_table = array_shift($extra_question_fields); + + $function = 'update_record'; + $questionidcolname = $this->questionid_column_name(); + $options = $DB->get_record($question_extension_table, array($questionidcolname => $question->id)); + if (!$options) { + $function = 'insert_record'; + $options = new stdClass; + $options->$questionidcolname = $question->id; + } + foreach ($extra_question_fields as $field) { + if (!isset($question->$field)) { + $result = new stdClass; + $result->error = "No data for field $field when saving " . + $this->name() . ' question id ' . $question->id; + return $result; + } + $options->$field = $question->$field; + } + + if (!$DB->{$function}($question_extension_table, $options)) { + $result = new stdClass; + $result->error = 'Could not save question options for ' . + $this->name() . ' question id ' . $question->id; + return $result; + } + } + + $extra_answer_fields = $this->extra_answer_fields(); + // TODO save the answers, with any extra data. + + return null; + } + + /** + * Loads the question type specific options for the question. + * + * This function loads any question type specific options for the + * question from the database into the question object. This information + * is placed in the $question->options field. A question type is + * free, however, to decide on a internal structure of the options field. + * @return bool Indicates success or failure. + * @param object $question The question object for the question. This object + * should be updated to include the question type + * specific information (it is passed by reference). + */ + function get_question_options(&$question) { + global $CFG, $DB, $OUTPUT; + + if (!isset($question->options)) { + $question->options = new stdClass(); + } + + $extra_question_fields = $this->extra_question_fields(); + if (is_array($extra_question_fields)) { + $question_extension_table = array_shift($extra_question_fields); + $extra_data = $DB->get_record($question_extension_table, array($this->questionid_column_name() => $question->id), implode(', ', $extra_question_fields)); + if ($extra_data) { + foreach ($extra_question_fields as $field) { + $question->options->$field = $extra_data->$field; + } + } else { + echo $OUTPUT->notification("Failed to load question options from the table $question_extension_table for questionid " . + $question->id); + return false; + } + } + + $extra_answer_fields = $this->extra_answer_fields(); + if (is_array($extra_answer_fields)) { + $answer_extension_table = array_shift($extra_answer_fields); + $question->options->answers = $DB->get_records_sql(" + SELECT qa.*, qax." . implode(', qax.', $extra_answer_fields) . " + FROM {question_answers} qa, {$answer_extension_table} qax + WHERE qa.questionid = ? AND qax.answerid = qa.id", array($question->id)); + if (!$question->options->answers) { + echo $OUTPUT->notification("Failed to load question answers from the table $answer_extension_table for questionid " . + $question->id); + return false; + } + } else { + // Don't check for success or failure because some question types do not use the answers table. + $question->options->answers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC'); + } + + return true; + } + + /** + * Deletes states from the question-type specific tables + * + * @param string $stateslist Comma separated list of state ids to be deleted + */ + function delete_states($stateslist) { + /// The default question type does not have any tables of its own + // therefore there is nothing to delete + + return true; + } + + /** + * Deletes the question-type specific data when a question is deleted. + * @param integer $question the question being deleted. + * @param integer $contextid the context this quesiotn belongs to. + */ + function delete_question($questionid, $contextid) { + global $DB; + + $this->delete_files($questionid, $contextid); + + $extra_question_fields = $this->extra_question_fields(); + if (is_array($extra_question_fields)) { + $question_extension_table = array_shift($extra_question_fields); + $DB->delete_records($question_extension_table, + array($this->questionid_column_name() => $questionid)); + } + + $extra_answer_fields = $this->extra_answer_fields(); + if (is_array($extra_answer_fields)) { + $answer_extension_table = array_shift($extra_answer_fields); + $DB->delete_records_select($answer_extension_table, + "answerid IN (SELECT qa.id FROM {question_answers} qa WHERE qa.question = ?)", array($questionid)); + } + + $DB->delete_records('question_answers', array('question' => $questionid)); + } + + /** + * Returns the number of question numbers which are used by the question + * + * This function returns the number of question numbers to be assigned + * to the question. Most question types will have length one; they will be + * assigned one number. The 'description' type, however does not use up a + * number and so has a length of zero. Other question types may wish to + * handle a bundle of questions and hence return a number greater than one. + * @return integer The number of question numbers which should be + * assigned to the question. + * @param object $question The question whose length is to be determined. + * Question type specific information is included. + */ + function actual_number_of_questions($question) { + // By default, each question is given one number + return 1; + } + + /** + * Creates empty session and response information for the question + * + * This function is called to start a question session. Empty question type + * specific session data (if any) and empty response data will be added to the + * state object. Session data is any data which must persist throughout the + * attempt possibly with updates as the user interacts with the + * question. This function does NOT create new entries in the database for + * the session; a call to the {@link save_session_and_responses} member will + * occur to do this. + * @return bool Indicates success or failure. + * @param object $question The question for which the session is to be + * created. Question type specific information is + * included. + * @param object $state The state to create the session for. Note that + * this will not have been saved in the database so + * there will be no id. This object will be updated + * to include the question type specific information + * (it is passed by reference). In particular, empty + * responses will be created in the ->responses + * field. + * @param object $cmoptions + * @param object $attempt The attempt for which the session is to be + * started. Questions may wish to initialize the + * session in different ways depending on the user id + * or time available for the attempt. + */ + function create_session_and_responses(&$question, &$state, $cmoptions, $attempt) { + // The default implementation should work for the legacy question types. + // Most question types with only a single form field for the student's response + // will use the empty string '' as the index for that one response. This will + // automatically be stored in and restored from the answer field in the + // question_states table. + $state->responses = array( + '' => '', + ); + return true; + } + + /** + * Restores the session data and most recent responses for the given state + * + * This function loads any session data associated with the question + * session in the given state from the database into the state object. + * In particular it loads the responses that have been saved for the given + * state into the ->responses member of the state object. + * + * Question types with only a single form field for the student's response + * will not need not restore the responses; the value of the answer + * field in the question_states table is restored to ->responses[''] + * before this function is called. Question types with more response fields + * should override this method and set the ->responses field to an + * associative array of responses. + * @return bool Indicates success or failure. + * @param object $question The question object for the question including any + * question type specific information. + * @param object $state The saved state to load the session for. This + * object should be updated to include the question + * type specific session information and responses + * (it is passed by reference). + */ + function restore_session_and_responses(&$question, &$state) { + // The default implementation does nothing (successfully) + return true; + } + + /** + * Saves the session data and responses for the given question and state + * + * This function saves the question type specific session data from the + * state object to the database. In particular for most question types it saves the + * responses from the ->responses member of the state object. The question type + * non-specific data for the state has already been saved in the question_states + * table and the state object contains the corresponding id and + * sequence number which may be used to index a question type specific table. + * + * Question types with only a single form field for the student's response + * which is contained in ->responses[''] will not have to save this response, + * it will already have been saved to the answer field of the question_states table. + * Question types with more response fields should override this method to convert + * the data the ->responses array into a single string field, and save it in the + * database. The implementation in the multichoice question type is a good model to follow. + * http://cvs.moodle.org/contrib/plugins/question/type/opaque/questiontype.php?view=markup + * has a solution that is probably quite generally applicable. + * @return bool Indicates success or failure. + * @param object $question The question object for the question including + * the question type specific information. + * @param object $state The state for which the question type specific + * data and responses should be saved. + */ + function save_session_and_responses(&$question, &$state) { + // The default implementation does nothing (successfully) + return true; + } + + /** + * Returns an array of values which will give full marks if graded as + * the $state->responses field + * + * The correct answer to the question in the given state, or an example of + * a correct answer if there are many, is returned. This is used by some question + * types in the {@link grade_responses()} function but it is also used by the + * question preview screen to fill in correct responses. + * @return mixed A response array giving the responses corresponding + * to the (or a) correct answer to the question. If there is + * no correct answer that scores 100% then null is returned. + * @param object $question The question for which the correct answer is to + * be retrieved. Question type specific information is + * available. + * @param object $state The state of the question, for which a correct answer is + * needed. Question type specific information is included. + */ + function get_correct_responses(&$question, &$state) { + /* The default implementation returns the response for the first answer + that gives full marks. */ + if ($question->options->answers) { + foreach ($question->options->answers as $answer) { + if (((int) $answer->fraction) === 1) { + return array('' => $answer->answer); + } + } + } + return null; + } + + /** + * Return an array of values with the texts for all possible responses stored + * for the question + * + * All answers are found and their text values isolated + * @return object A mixed object + * ->id question id. Needed to manage random questions: + * it's the id of the actual question presented to user in a given attempt + * ->responses An array of values giving the responses corresponding + * to all answers to the question. Answer ids are used as keys. + * The text and partial credit are the object components + * @param object $question The question for which the answers are to + * be retrieved. Question type specific information is + * available. + */ + // ULPGC ecastro + function get_all_responses(&$question, &$state) { + if (isset($question->options->answers) && is_array($question->options->answers)) { + $answers = array(); + foreach ($question->options->answers as $aid=>$answer) { + $r = new stdClass; + $r->answer = $answer->answer; + $r->credit = $answer->fraction; + $answers[$aid] = $r; + } + $result = new stdClass; + $result->id = $question->id; + $result->responses = $answers; + return $result; + } else { + return null; + } + } + /** + * The difference between this method an get_all_responses is that this + * method is not passed a state object. It is the possible answers to a + * question no matter what the state. + * This method is not called for random questions. + * @return array of possible answers. + */ + function get_possible_responses(&$question) { + static $responses = array(); + if (!isset($responses[$question->id])){ + $responses[$question->id] = $this->get_all_responses($question, new object()); + } + return array($question->id => $responses[$question->id]->responses); + } + + /** + * @param object $question + * @return mixed either a integer score out of 1 that the average random + * guess by a student might give or an empty string which means will not + * calculate. + */ + function get_random_guess_score($question) { + return 0; + } + /** + * Return the actual response to the question in a given state + * for the question. Text is not yet formatted for output. + * + * @return mixed An array containing the response or reponses (multiple answer, match) + * given by the user in a particular attempt. + * @param object $question The question for which the correct answer is to + * be retrieved. Question type specific information is + * available. + * @param object $state The state object that corresponds to the question, + * for which a correct answer is needed. Question + * type specific information is included. + */ + // ULPGC ecastro + function get_actual_response($question, $state) { + if (!empty($state->responses)) { + $responses[] = $state->responses['']; + } else { + $responses[] = ''; + } + return $responses; + } + + function get_actual_response_details($question, $state) { + $response = array_shift($this->get_actual_response($question, $state)); + $teacherresponses = $this->get_possible_responses($question, $state); + //only one response + list($tsubqid, $tresponses) = each($teacherresponses); + $responsedetail = new stdClass(); + $responsedetail->subqid = $tsubqid; + $responsedetail->response = $response; + if ($aid = $this->check_response($question, $state)){ + $responsedetail->aid = $aid; + } else { + foreach ($tresponses as $aid => $tresponse){ + if ($tresponse->answer == $response){ + $responsedetail->aid = $aid; + break; + } + } + } + if (isset($responsedetail->aid)){ + $responsedetail->credit = $tresponses[$aid]->credit; + } else { + $responsedetail->aid = 0; + $responsedetail->credit = 0; + } + return array($responsedetail); + } + + // ULPGC ecastro + function get_fractional_grade(&$question, &$state) { + $grade = $state->grade; + if ($question->maxgrade > 0) { + return (float)($grade / $question->maxgrade); + } else { + return (float)$grade; + } + } + + + /** + * Checks if the response given is correct and returns the id + * + * @return int The ide number for the stored answer that matches the response + * given by the user in a particular attempt. + * @param object $question The question for which the correct answer is to + * be retrieved. Question type specific information is + * available. + * @param object $state The state object that corresponds to the question, + * for which a correct answer is needed. Question + * type specific information is included. + */ + // ULPGC ecastro + function check_response(&$question, &$state){ + return false; + } + + // Used by the following function, so that it only returns results once per quiz page. + private $htmlheadalreadydone = false; + /** + * Hook to allow question types to include required JavaScrip or CSS on pages + * where they are going to be printed. + * + * If this question type requires JavaScript to function, + * then this method, which will be called before print_header on any page + * where this question is going to be printed, is a chance to call + * $PAGE->requires->js, and so on. + * + * The two parameters match the first two parameters of print_question. + * + * @param object $question The question object. + * @param object $state The state object. + */ + function get_html_head_contributions(&$question, &$state) { + // We only do this once for this question type, no matter how often this + // method is called on one page. + if ($this->htmlheadalreadydone) { + return; + } + $this->htmlheadalreadydone = true; + + // By default, we link to any of the files script.js or script.php that + // exist in the plugin folder. + $this->find_standard_scripts(); + } + + /** + * Like @see{get_html_head_contributions}, but this method is for CSS and + * JavaScript required on the question editing page question/question.php. + */ + function get_editing_head_contributions() { + // By default, we link to any of the files styles.css, styles.php, + // script.js or script.php that exist in the plugin folder. + // Core question types should not use this mechanism. Their styles + // should be included in the standard theme. + $this->find_standard_scripts(); + } + + /** + * Utility method used by @see{get_html_head_contributions} and + * @see{get_editing_head_contributions}. This looks for any of the files + * script.js or script.php that exist in the plugin folder and ensures they + * get included. + */ + protected function find_standard_scripts() { + global $PAGE; + + $plugindir = $this->plugin_dir(); + $plugindirrel = 'question/type/' . $this->name(); + + if (file_exists($plugindir . '/script.js')) { + $PAGE->requires->js('/' . $plugindirrel . '/script.js'); + } + if (file_exists($plugindir . '/script.php')) { + $PAGE->requires->js('/' . $plugindirrel . '/script.php'); + } + } + + /** + * Prints the question including the number, grading details, content, + * feedback and interactions + * + * This function prints the question including the question number, + * grading details, content for the question, any feedback for the previously + * submitted responses and the interactions. The default implementation calls + * various other methods to print each of these parts and most question types + * will just override those methods. + * @param object $question The question to be rendered. Question type + * specific information is included. The + * maximum possible grade is in ->maxgrade. The name + * prefix for any named elements is in ->name_prefix. + * @param object $state The state to render the question in. The grading + * information is in ->grade, ->raw_grade and + * ->penalty. The current responses are in + * ->responses. This is an associative array (or the + * empty string or null in the case of no responses + * submitted). The last graded state is in + * ->last_graded (hence the most recently graded + * responses are in ->last_graded->responses). The + * question type specific information is also + * included. + * @param integer $number The number for this question. + * @param object $cmoptions + * @param object $options An object describing the rendering options. + */ + function print_question(&$question, &$state, $number, $cmoptions, $options, $context=null) { + /* The default implementation should work for most question types + provided the member functions it calls are overridden where required. + The layout is determined by the template question.html */ + + global $CFG, $OUTPUT; + + $context = $this->get_context_by_category_id($question->category); + $question->questiontext = quiz_rewrite_question_urls($question->questiontext, 'pluginfile.php', $context->id, 'question', 'questiontext', array($state->attempt, $state->question), $question->id); + + $question->generalfeedback = quiz_rewrite_question_urls($question->generalfeedback, 'pluginfile.php', $context->id, 'question', 'generalfeedback', array($state->attempt, $state->question), $question->id); + + $isgraded = question_state_is_graded($state->last_graded); + + if (isset($question->randomquestionid)) { + $actualquestionid = $question->randomquestionid; + } else { + $actualquestionid = $question->id; + } + + // For editing teachers print a link to an editing popup window + $editlink = $this->get_question_edit_link($question, $cmoptions, $options); + + $generalfeedback = ''; + if ($isgraded && $options->generalfeedback) { + $generalfeedback = $this->format_text($question->generalfeedback, + $question->generalfeedbackformat, $cmoptions); + } + + $grade = ''; + if ($question->maxgrade > 0 && $options->scores) { + if ($cmoptions->optionflags & QUESTION_ADAPTIVE) { + if ($isgraded) { + $grade = question_format_grade($cmoptions, $state->last_graded->grade).'/'; + } else { + $grade = '--/'; + } + } + $grade .= question_format_grade($cmoptions, $question->maxgrade); + } + + $formatoptions = new stdClass; + $formatoptions->para = false; + $comment = format_text($state->manualcomment, $state->manualcommentformat, + $formatoptions, $cmoptions->course); + $commentlink = ''; + + if (!empty($options->questioncommentlink)) { + $strcomment = get_string('commentorgrade', 'quiz'); + + $link = new moodle_url("$options->questioncommentlink?attempt=$state->attempt&question=$actualquestionid"); + $action = new popup_action('click', $link, 'commentquestion', array('height' => 480, 'width' => 750)); + $commentlink = $OUTPUT->container($OUTPUT->action_link($link, $strcomment, $action), 'commentlink'); + } + + $history = $this->history($question, $state, $number, $cmoptions, $options); + + include "$CFG->dirroot/question/type/question.html"; + } + + /** + * Render the question flag, assuming $flagsoption allows it. You will probably + * never need to override this method. + * + * @param object $question the question + * @param object $state its current state + * @param integer $flagsoption the option that says whether flags should be displayed. + */ + protected function print_question_flag($question, $state, $flagsoption) { + global $CFG, $PAGE; + switch ($flagsoption) { + case QUESTION_FLAGSSHOWN: + $flagcontent = $this->get_question_flag_tag($state->flagged); + break; + case QUESTION_FLAGSEDITABLE: + $id = $question->name_prefix . '_flagged'; + if ($state->flagged) { + $checked = 'checked="checked" '; + } else { + $checked = ''; + } + $qsid = $state->questionsessionid; + $aid = $state->attempt; + $qid = $state->question; + $checksum = question_get_toggleflag_checksum($aid, $qid, $qsid); + $postdata = "qsid=$qsid&aid=$aid&qid=$qid&checksum=$checksum&sesskey=" . + sesskey() . '&newstate='; + $flagcontent = '' . + '' . + '' . "\n"; + question_init_qengine_js(); + break; + default: + $flagcontent = ''; + } + if ($flagcontent) { + echo '
' . $flagcontent . "
\n"; + } + } + + /** + * Work out the actual img tag needed for the flag + * + * @param boolean $flagged whether the question is currently flagged. + * @param string $id an id to be added as an attribute to the img (optional). + * @return string the img tag. + */ + protected function get_question_flag_tag($flagged, $id = '') { + global $OUTPUT; + if ($id) { + $id = 'id="' . $id . '" '; + } + if ($flagged) { + $img = 'i/flagged'; + } else { + $img = 'i/unflagged'; + } + return '' . get_string('flagthisquestion', 'question') . ''; + } + + /** + * Get a link to an edit icon for this question, if the current user is allowed + * to edit it. + * + * @param object $question the question object. + * @param object $cmoptions the options from the module. If $cmoptions->thispageurl is set + * then the link will be to edit the question in this browser window, then return to + * $cmoptions->thispageurl. Otherwise the link will be to edit in a popup. + * @return string the HTML of the link, or nothing it the currenty user is not allowed to edit. + */ + function get_question_edit_link($question, $cmoptions, $options) { + global $CFG, $OUTPUT; + + /// Is this user allowed to edit this question? + if (!empty($options->noeditlink) || !question_has_capability_on($question, 'edit')) { + return ''; + } + + /// Work out the right URL. + $url = new moodle_url('/question/question.php', array('id' => $question->id)); + if (!empty($cmoptions->cmid)) { + $url->param('cmid', $cmoptions->cmid); + } else if (!empty($cmoptions->course)) { + $url->param('courseid', $cmoptions->course); + } else { + print_error('missingcourseorcmidtolink', 'question'); + } + + $icon = new pix_icon('t/edit', get_string('edit')); + + $action = null; + if (!empty($cmoptions->thispageurl)) { + // The module allow editing in the same window, print an ordinary + // link with a returnurl. + $url->param('returnurl', $cmoptions->thispageurl); + } else { + // We have to edit in a pop-up. + $url->param('inpopup', 1); + $action = new popup_action('click', $link, 'editquestion'); + } + + return $OUTPUT->action_icon($url, $icon, $action); + } + + /** + * Print history of responses + * + * Used by print_question() + */ + function history($question, $state, $number, $cmoptions, $options) { + global $DB, $OUTPUT; + + if (empty($options->history)) { + return ''; + } + + $params = array('aid' => $state->attempt); + if (isset($question->randomquestionid)) { + $params['qid'] = $question->randomquestionid; + $randomprefix = 'random' . $question->id . '-'; + } else { + $params['qid'] = $question->id; + $randomprefix = ''; + } + if ($options->history == 'all') { + $eventtest = 'event > 0'; + } else { + $eventtest = 'event IN (' . QUESTION_EVENTS_GRADED . ')'; + } + $states = $DB->get_records_select('question_states', + 'attempt = :aid AND question = :qid AND ' . $eventtest, $params, 'seq_number ASC'); + if (count($states) <= 1) { + return ''; + } + + $strreviewquestion = get_string('reviewresponse', 'quiz'); + $table = new html_table(); + $table->width = '100%'; + $table->head = array ( + get_string('numberabbr', 'quiz'), + get_string('action', 'quiz'), + get_string('response', 'quiz'), + get_string('time'), + ); + if ($options->scores) { + $table->head[] = get_string('score', 'quiz'); + $table->head[] = get_string('grade', 'quiz'); + } + + foreach ($states as $st) { + if ($randomprefix && strpos($st->answer, $randomprefix) === 0) { + $st->answer = substr($st->answer, strlen($randomprefix)); + } + $st->responses[''] = $st->answer; + $this->restore_session_and_responses($question, $st); + + if ($state->id == $st->id) { + $link = '' . $st->seq_number . ''; + } else if (isset($options->questionreviewlink)) { + $reviewlink = new moodle_url($options->questionreviewlink); + $reviewlink->params(array('state'=>$st->id,'question'=>$question->id)); + $link = new moodle_url($reviewlink); + $action = new popup_action('click', $link, 'reviewquestion', array('height' => 450, 'width' => 650)); + $link = $OUTPUT->action_link($link, $st->seq_number, $action, array('title'=>$strreviewquestion)); + } else { + $link = $st->seq_number; + } + + if ($state->id == $st->id) { + $b = ''; + $be = ''; + } else { + $b = ''; + $be = ''; + } + + $data = array ( + $link, + $b.get_string('event'.$st->event, 'quiz').$be, + $b.$this->response_summary($question, $st).$be, + $b.userdate($st->timestamp, get_string('timestr', 'quiz')).$be, + ); + if ($options->scores) { + $data[] = $b.question_format_grade($cmoptions, $st->raw_grade).$be; + $data[] = $b.question_format_grade($cmoptions, $st->raw_grade).$be; + } + $table->data[] = $data; + } + return html_writer::table($table); + } + + /** + * Prints the score obtained and maximum score available plus any penalty + * information + * + * This function prints a summary of the scoring in the most recently + * graded state (the question may not have been submitted for marking at + * the current state). The default implementation should be suitable for most + * question types. + * @param object $question The question for which the grading details are + * to be rendered. Question type specific information + * is included. The maximum possible grade is in + * ->maxgrade. + * @param object $state The state. In particular the grading information + * is in ->grade, ->raw_grade and ->penalty. + * @param object $cmoptions + * @param object $options An object describing the rendering options. + */ + function print_question_grading_details(&$question, &$state, $cmoptions, $options) { + /* The default implementation prints the number of marks if no attempt + has been made. Otherwise it displays the grade obtained out of the + maximum grade available and a warning if a penalty was applied for the + attempt and displays the overall grade obtained counting all previous + responses (and penalties) */ + + if (QUESTION_EVENTDUPLICATE == $state->event) { + echo ' '; + print_string('duplicateresponse', 'quiz'); + } + if ($question->maxgrade > 0 && $options->scores) { + if (question_state_is_graded($state->last_graded)) { + // Display the grading details from the last graded state + $grade = new stdClass; + $grade->cur = question_format_grade($cmoptions, $state->last_graded->grade); + $grade->max = question_format_grade($cmoptions, $question->maxgrade); + $grade->raw = question_format_grade($cmoptions, $state->last_graded->raw_grade); + + // let student know wether the answer was correct + $class = question_get_feedback_class($state->last_graded->raw_grade / + $question->maxgrade); + echo '
' . get_string($class, 'quiz') . '
'; + + echo '
'; + // print grade for this submission + print_string('gradingdetails', 'quiz', $grade); + if ($cmoptions->penaltyscheme) { + // print details of grade adjustment due to penalties + if ($state->last_graded->raw_grade > $state->last_graded->grade){ + echo ' '; + print_string('gradingdetailsadjustment', 'quiz', $grade); + } + // print info about new penalty + // penalty is relevant only if the answer is not correct and further attempts are possible + if (($state->last_graded->raw_grade < $question->maxgrade / 1.01) + and (QUESTION_EVENTCLOSEANDGRADE != $state->event)) { + + if ('' !== $state->last_graded->penalty && ((float)$state->last_graded->penalty) > 0.0) { + // A penalty was applied so display it + echo ' '; + print_string('gradingdetailspenalty', 'quiz', question_format_grade($cmoptions, $state->last_graded->penalty)); + } else { + /* No penalty was applied even though the answer was + not correct (eg. a syntax error) so tell the student + that they were not penalised for the attempt */ + echo ' '; + print_string('gradingdetailszeropenalty', 'quiz'); + } + } + } + echo '
'; + } + } + } + + /** + * Prints the main content of the question including any interactions + * + * This function prints the main content of the question including the + * interactions for the question in the state given. The last graded responses + * are printed or indicated and the current responses are selected or filled in. + * Any names (eg. for any form elements) are prefixed with $question->name_prefix. + * This method is called from the print_question method. + * @param object $question The question to be rendered. Question type + * specific information is included. The name + * prefix for any named elements is in ->name_prefix. + * @param object $state The state to render the question in. The grading + * information is in ->grade, ->raw_grade and + * ->penalty. The current responses are in + * ->responses. This is an associative array (or the + * empty string or null in the case of no responses + * submitted). The last graded state is in + * ->last_graded (hence the most recently graded + * responses are in ->last_graded->responses). The + * question type specific information is also + * included. + * The state is passed by reference because some adaptive + * questions may want to update it during rendering + * @param object $cmoptions + * @param object $options An object describing the rendering options. + */ + function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) { + /* This default implementation prints an error and must be overridden + by all question type implementations, unless the default implementation + of print_question has been overridden. */ + global $OUTPUT; + echo $OUTPUT->notification('Error: Question formulation and input controls has not' + .' been implemented for question type '.$this->name()); + } + + function check_file_access($question, $state, $options, $contextid, $component, + $filearea, $args) { + + if ($component == 'question' && $filearea == 'questiontext') { + // Question text always visible. + return true; + + } else if ($component == 'question' && $filearea = 'generalfeedback') { + return $options->generalfeedback && question_state_is_graded($state->last_graded); + + } else { + // Unrecognised component or filearea. + return false; + } + } + + /** + * Prints the submit button(s) for the question in the given state + * + * This function prints the submit button(s) for the question in the + * given state. The name of any button created will be prefixed with the + * unique prefix for the question in $question->name_prefix. The suffix + * 'submit' is reserved for the single question submit button and the suffix + * 'validate' is reserved for the single question validate button (for + * question types which support it). Other suffixes will result in a response + * of that name in $state->responses which the printing and grading methods + * can then use. + * @param object $question The question for which the submit button(s) are to + * be rendered. Question type specific information is + * included. The name prefix for any + * named elements is in ->name_prefix. + * @param object $state The state to render the buttons for. The + * question type specific information is also + * included. + * @param object $cmoptions + * @param object $options An object describing the rendering options. + */ + function print_question_submit_buttons(&$question, &$state, $cmoptions, $options) { + // The default implementation should be suitable for most question types. + // It prints a mark button in the case where individual marking is allowed. + if (($cmoptions->optionflags & QUESTION_ADAPTIVE) and !$options->readonly) { + echo ''; + } + } + + /** + * Return a summary of the student response + * + * This function returns a short string of no more than a given length that + * summarizes the student's response in the given $state. This is used for + * example in the response history table. This string should already be + * formatted for output. + * @return string The summary of the student response + * @param object $question + * @param object $state The state whose responses are to be summarized + * @param int $length The maximum length of the returned string + */ + function response_summary($question, $state, $length = 80, $formatting = true) { + // This should almost certainly be overridden + $responses = $this->get_actual_response($question, $state); + if ($formatting){ + $responses = $this->format_responses($responses, $question->questiontextformat); + } + $responses = implode('; ', $responses); + return shorten_text($responses, $length); + } + /** + * @param array responses is an array of responses. + * @return formatted responses + */ + function format_responses($responses, $format){ + $toreturn = array(); + foreach ($responses as $response){ + $toreturn[] = $this->format_response($response, $format); + } + return $toreturn; + } + /** + * @param string response is a response. + * @return formatted response + */ + function format_response($response, $format){ + return s($response); + } + /** + * Renders the question for printing and returns the LaTeX source produced + * + * This function should render the question suitable for a printed problem + * or solution sheet in LaTeX and return the rendered output. + * @return string The LaTeX output. + * @param object $question The question to be rendered. Question type + * specific information is included. + * @param object $state The state to render the question in. The + * question type specific information is also + * included. + * @param object $cmoptions + * @param string $type Indicates if the question or the solution is to be + * rendered with the values 'question' and + * 'solution'. + */ + function get_texsource(&$question, &$state, $cmoptions, $type) { + // The default implementation simply returns a string stating that + // the question is only available online. + + return get_string('onlineonly', 'texsheet'); + } + + /** + * Compares two question states for equivalence of the student's responses + * + * The responses for the two states must be examined to see if they represent + * equivalent answers to the question by the student. This method will be + * invoked for each of the previous states of the question before grading + * occurs. If the student is found to have already attempted the question + * with equivalent responses then the attempt at the question is ignored; + * grading does not occur and the state does not change. Thus they are not + * penalized for this case. + * @return boolean + * @param object $question The question for which the states are to be + * compared. Question type specific information is + * included. + * @param object $state The state of the question. The responses are in + * ->responses. This is the only field of $state + * that it is safe to use. + * @param object $teststate The state whose responses are to be + * compared. The state will be of the same age or + * older than $state. If possible, the method should + * only use the field $teststate->responses, however + * any field that is set up by restore_session_and_responses + * can be used. + */ + function compare_responses(&$question, $state, $teststate) { + // The default implementation performs a comparison of the response + // arrays. The ordering of the arrays does not matter. + // Question types may wish to override this (eg. to ignore trailing + // white space or to make "7.0" and "7" compare equal). + + // In php neither == nor === compare arrays the way you want. The following + // ensures that the arrays have the same keys, with the same values. + $result = false; + $diff1 = array_diff_assoc($state->responses, $teststate->responses); + if (empty($diff1)) { + $diff2 = array_diff_assoc($teststate->responses, $state->responses); + $result = empty($diff2); + } + + return $result; + } + + /** + * Checks whether a response matches a given answer + * + * This method only applies to questions that use teacher-defined answers + * + * @return boolean + */ + function test_response(&$question, &$state, $answer) { + $response = isset($state->responses['']) ? $state->responses[''] : ''; + return ($response == $answer->answer); + } + + /** + * Performs response processing and grading + * + * This function performs response processing and grading and updates + * the state accordingly. + * @return boolean Indicates success or failure. + * @param object $question The question to be graded. Question type + * specific information is included. + * @param object $state The state of the question to grade. The current + * responses are in ->responses. The last graded state + * is in ->last_graded (hence the most recently graded + * responses are in ->last_graded->responses). The + * question type specific information is also + * included. The ->raw_grade and ->penalty fields + * must be updated. The method is able to + * close the question session (preventing any further + * attempts at this question) by setting + * $state->event to QUESTION_EVENTCLOSEANDGRADE + * @param object $cmoptions + */ + function grade_responses(&$question, &$state, $cmoptions) { + // The default implementation uses the test_response method to + // compare what the student entered against each of the possible + // answers stored in the question, and uses the grade from the + // first one that matches. It also sets the marks and penalty. + // This should be good enought for most simple question types. + + $state->raw_grade = 0; + foreach($question->options->answers as $answer) { + if($this->test_response($question, $state, $answer)) { + $state->raw_grade = $answer->fraction; + break; + } + } + + // Make sure we don't assign negative or too high marks. + $state->raw_grade = min(max((float) $state->raw_grade, + 0.0), 1.0) * $question->maxgrade; + + // Update the penalty. + $state->penalty = $question->penalty * $question->maxgrade; + + // mark the state as graded + $state->event = ($state->event == QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE; + + return true; + } + + /** + * Returns true if the editing wizard is finished, false otherwise. + * + * The default implementation returns true, which is suitable for all question- + * types that only use one editing form. This function is used in + * question.php to decide whether we can regrade any states of the edited + * question and redirect to edit.php. + * + * The dataset dependent question-type, which is extended by the calculated + * question-type, overwrites this method because it uses multiple pages (i.e. + * a wizard) to set up the question and associated datasets. + * + * @param object $form The data submitted by the previous page. + * + * @return boolean Whether the wizard's last page was submitted or not. + */ + function finished_edit_wizard(&$form) { + //In the default case there is only one edit page. + return true; + } + + /** + * Call format_text from weblib.php with the options appropriate to question types. + * + * @param string $text the text to format. + * @param integer $text the type of text. Normally $question->questiontextformat. + * @param object $cmoptions the context the string is being displayed in. Only $cmoptions->course is used. + * @return string the formatted text. + */ + function format_text($text, $textformat, $cmoptions = NULL) { + $formatoptions = new stdClass; + $formatoptions->noclean = true; + $formatoptions->para = false; + return format_text($text, $textformat, $formatoptions, $cmoptions === NULL ? NULL : $cmoptions->course); + } + + /** + * @return the best link to pass to print_error. + * @param $cmoptions as passed in from outside. + */ + function error_link($cmoptions) { + global $CFG; + $cm = get_coursemodule_from_instance('quiz', $cmoptions->id); + if (!empty($cm->id)) { + return $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; + } else if (!empty($cm->course)) { + return $CFG->wwwroot . '/course/view.php?id=' . $cm->course; + } else { + return ''; + } + } + +/// IMPORT/EXPORT FUNCTIONS ///////////////// + + /* + * Imports question from the Moodle XML format + * + * Imports question using information from extra_question_fields function + * If some of you fields contains id's you'll need to reimplement this + */ + function import_from_xml($data, $question, $format, $extra=null) { + $question_type = $data['@']['type']; + if ($question_type != $this->name()) { + return false; + } + + $extraquestionfields = $this->extra_question_fields(); + if (!is_array($extraquestionfields)) { + return false; + } + + //omit table name + array_shift($extraquestionfields); + $qo = $format->import_headers($data); + $qo->qtype = $question_type; + + foreach ($extraquestionfields as $field) { + $qo->$field = $format->getpath($data, array('#',$field,0,'#'), $qo->$field); + } + + // run through the answers + $answers = $data['#']['answer']; + $a_count = 0; + $extraasnwersfields = $this->extra_answer_fields(); + if (is_array($extraasnwersfields)) { + //TODO import the answers, with any extra data. + } else { + foreach ($answers as $answer) { + $ans = $format->import_answer($answer); + $qo->answer[$a_count] = $ans->answer; + $qo->fraction[$a_count] = $ans->fraction; + $qo->feedback[$a_count] = $ans->feedback; + ++$a_count; + } + } + return $qo; + } + + /* + * Export question to the Moodle XML format + * + * Export question using information from extra_question_fields function + * If some of you fields contains id's you'll need to reimplement this + */ + function export_to_xml($question, $format, $extra=null) { + $extraquestionfields = $this->extra_question_fields(); + if (!is_array($extraquestionfields)) { + return false; + } + + //omit table name + array_shift($extraquestionfields); + $expout=''; + foreach ($extraquestionfields as $field) { + $exportedvalue = $question->options->$field; + if (!empty($exportedvalue) && htmlspecialchars($exportedvalue) != $exportedvalue) { + $exportedvalue = ''; + } + $expout .= " <$field>{$exportedvalue}\n"; + } + + $extraasnwersfields = $this->extra_answer_fields(); + if (is_array($extraasnwersfields)) { + //TODO export answers with any extra data + } else { + foreach ($question->options->answers as $answer) { + $percent = 100 * $answer->fraction; + $expout .= " \n"; + $expout .= $format->writetext($answer->answer, 3, false); + $expout .= " \n"; + $expout .= $format->writetext($answer->feedback, 4, false); + $expout .= " \n"; + $expout .= " \n"; + } + } + return $expout; + } + + /** + * Abstract function implemented by each question type. It runs all the code + * required to set up and save a question of any type for testing purposes. + * Alternate DB table prefix may be used to facilitate data deletion. + */ + function generate_test($name, $courseid=null) { + $form = new stdClass(); + $form->name = $name; + $form->questiontextformat = 1; + $form->questiontext = 'test question, generated by script'; + $form->defaultgrade = 1; + $form->penalty = 0.1; + $form->generalfeedback = "Well done"; + + $context = get_context_instance(CONTEXT_COURSE, $courseid); + $newcategory = question_make_default_categories(array($context)); + $form->category = $newcategory->id . ',1'; + + $question = new stdClass(); + $question->courseid = $courseid; + $question->qtype = $this->qtype; + return array($form, $question); + } + + /** + * Get question context by category id + * @param int $category + * @return object $context + */ + function get_context_by_category_id($category) { + global $DB; + $contextid = $DB->get_field('question_categories', 'contextid', array('id'=>$category)); + $context = get_context_instance_by_id($contextid); + return $context; + } + + /** + * Save the file belonging to one text field. + * + * @param array $field the data from the form (or from import). This will + * normally have come from the formslib editor element, so it will be an + * array with keys 'text', 'format' and 'itemid'. However, when we are + * importing, it will be an array with keys 'text', 'format' and 'files' + * @param object $context the context the question is in. + * @param string $component indentifies the file area question. + * @param string $filearea indentifies the file area questiontext, generalfeedback,answerfeedback. + * @param integer $itemid identifies the file area. + * + * @return string the text for this field, after files have been processed. + */ + protected function import_or_save_files($field, $context, $component, $filearea, $itemid) { + if (!empty($field['itemid'])) { + // This is the normal case. We are safing the questions editing form. + return file_save_draft_area_files($field['itemid'], $context->id, $component, + $filearea, $itemid, $this->fileoptions, trim($field['text'])); + + } else if (!empty($field['files'])) { + // This is the case when we are doing an import. + foreach ($field['files'] as $file) { + $this->import_file($context, $component, $filearea, $itemid, $file); + } + } + return trim($field['text']); + } + + /** + * Move all the files belonging to this question from one context to another. + * @param integer $questionid the question being moved. + * @param integer $oldcontextid the context it is moving from. + * @param integer $newcontextid the context it is moving to. + */ + public function move_files($questionid, $oldcontextid, $newcontextid) { + $fs = get_file_storage(); + $fs->move_area_files_to_new_context($oldcontextid, + $newcontextid, 'question', 'questiontext', $questionid); + $fs->move_area_files_to_new_context($oldcontextid, + $newcontextid, 'question', 'generalfeedback', $questionid); + } + + /** + * Move all the files belonging to this question's answers when the question + * is moved from one context to another. + * @param integer $questionid the question being moved. + * @param integer $oldcontextid the context it is moving from. + * @param integer $newcontextid the context it is moving to. + * @param boolean $answerstoo whether there is an 'answer' question area, + * as well as an 'answerfeedback' one. Default false. + */ + protected function move_files_in_answers($questionid, $oldcontextid, $newcontextid, $answerstoo = false) { + global $DB; + $fs = get_file_storage(); + + $answerids = $DB->get_records_menu('question_answers', + array('question' => $questionid), 'id', 'id,1'); + foreach ($answerids as $answerid => $notused) { + if ($answerstoo) { + $fs->move_area_files_to_new_context($oldcontextid, + $newcontextid, 'question', 'answer', $answerid); + } + $fs->move_area_files_to_new_context($oldcontextid, + $newcontextid, 'question', 'answerfeedback', $answerid); + } + } + + /** + * Delete all the files belonging to this question. + * @param integer $questionid the question being deleted. + * @param integer $contextid the context the question is in. + */ + protected function delete_files($questionid, $contextid) { + $fs = get_file_storage(); + $fs->delete_area_files($contextid, 'question', 'questiontext', $questionid); + $fs->delete_area_files($contextid, 'question', 'generalfeedback', $questionid); + } + + /** + * Delete all the files belonging to this question's answers. + * @param integer $questionid the question being deleted. + * @param integer $contextid the context the question is in. + * @param boolean $answerstoo whether there is an 'answer' question area, + * as well as an 'answerfeedback' one. Default false. + */ + protected function delete_files_in_answers($questionid, $contextid, $answerstoo = false) { + global $DB; + $fs = get_file_storage(); + + $answerids = $DB->get_records_menu('question_answers', + array('question' => $questionid), 'id', 'id,1'); + foreach ($answerids as $answerid => $notused) { + if ($answerstoo) { + $fs->delete_area_files($contextid, 'question', 'answer', $answerid); + } + $fs->delete_area_files($contextid, 'question', 'answerfeedback', $answerid); + } + } + + function import_file($context, $component, $filearea, $itemid, $file) { + $fs = get_file_storage(); + $record = new stdclass; + if (is_object($context)) { + $record->contextid = $context->id; + } else { + $record->contextid = $context; + } + $record->component = $component; + $record->filearea = $filearea; + $record->itemid = $itemid; + $record->filename = $file->name; + $record->filepath = '/'; + return $fs->create_file_from_string($record, $this->decode_file($file)); + } + + function decode_file($file) { + switch ($file->encoding) { + case 'base64': + default: + return base64_decode($file->content); + } + } +} diff --git a/question/type/questionbase.php b/question/type/questionbase.php new file mode 100644 index 00000000000..bc3b8910a80 --- /dev/null +++ b/question/type/questionbase.php @@ -0,0 +1,787 @@ +. + + +/** + * This file defines the class {@link question_definition} and its subclasses. + * + * @package moodlecore + * @subpackage questiontypes + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * The definition of a question of a particular type. + * + * This class is a close match to the question table in the database. + * Definitions of question of a particular type normally subclass one of the + * more specific classes {@link question_with_responses}, + * {@link question_graded_automatically} or {@link question_information_item}. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_definition { + /** @var integer id of the question in the datase, or null if this question + * is not in the database. */ + public $id; + + /** @var integer question category id. */ + public $category; + + /** @var integer parent question id. */ + public $parent = 0; + + /** @var question_type the question type this question is. */ + public $qtype; + + /** @var string question name. */ + public $name; + + /** @var string question text. */ + public $questiontext; + + /** @var integer question test format. */ + public $questiontextformat; + + /** @var string question general feedback. */ + public $generalfeedback; + + /** @var number what this quetsion is marked out of, by default. */ + public $defaultmark = 1; + + /** @var integer How many question numbers this question consumes. */ + public $length = 1; + + /** @var number penalty factor of this question. */ + public $penalty = 0; + + /** @var string unique identifier of this question. */ + public $stamp; + + /** @var string unique identifier of this version of this question. */ + public $version; + + /** @var boolean whethre this question has been deleted/hidden in the question bank. */ + public $hidden = 0; + + /** @var integer timestamp when this question was created. */ + public $timecreated; + + /** @var integer timestamp when this question was modified. */ + public $timemodified; + + /** @var integer userid of the use who created this question. */ + public $createdby; + + /** @var integer userid of the use who modified this question. */ + public $modifiedby; + + /** @var array of question_hints. */ + public $hints = array(); + + /** + * Constructor. Normally to get a question, you call + * {@link question_bank::load_question()}, but questions can be created + * directly, for example in unit test code. + * @return unknown_type + */ + public function __construct() { + } + + /** + * @return the name of the question type (for example multichoice) that this + * question is. + */ + public function get_type_name() { + return $this->qtype->name(); + } + + /** + * Creat the appropriate behaviour for an attempt at this quetsion, + * given the desired (archetypal) behaviour. + * + * This default implementation will suit most normal graded questions. + * + * If your question is of a patricular type, then it may need to do something + * different. For example, if your question can only be graded manually, then + * it should probably return a manualgraded behaviour, irrespective of + * what is asked for. + * + * If your question wants to do somthing especially complicated is some situations, + * then you may wish to return a particular behaviour related to the + * one asked for. For example, you migth want to return a + * qbehaviour_interactive_adapted_for_myqtype. + * + * @param question_attempt $qa the attempt we are creating an behaviour for. + * @param string $preferredbehaviour the requested type of behaviour. + * @return question_behaviour the new behaviour object. + */ + public function make_behaviour(question_attempt $qa, $preferredbehaviour) { + return question_engine::make_archetypal_behaviour($preferredbehaviour, $qa); + } + + /** + * Initialise the first step of an attempt at this quetsion. + * + * For example, the multiple choice question type uses this method to + * randomly shuffle the choices, if that option has been set in the question. + * It then stores that order by calling $step->set_qt_var(...). + * + * @param question_attempt_step $step the step to be initialised. + */ + public function init_first_step(question_attempt_step $step) { + } + + /** + * 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. + * @return string|null a plain text summary of this question. + */ + public function get_question_summary() { + return html_to_text($this->format_questiontext(), 0, false); + } + + /** + * Some questions can return a negative mark if the student gets it wrong. + * + * This method returns the lowest mark the question can return, on the + * fraction scale. that is, where the maximum possible mark is 1.0. + * + * @return number minimum fraction this question will ever return. + */ + public function get_min_fraction() { + return 0; + } + + /** + * Given a response, rest the parts that are wrong. + * @param array $response a response + * @return array a cleaned up response with the wrong bits reset. + */ + public function clear_wrong_from_response(array $response) { + return array(); + } + + /** + * Return the number of subparts of this response that are right. + * @param array $response a response + * @return array with two elements, the number of correct subparts, and + * the total number of subparts. + */ + public function get_num_parts_right(array $response) { + return array(null, null); + } + + /** + * @return qtype_renderer the renderer to use for outputting this question. + */ + public function get_renderer() { + return renderer_factory::get_renderer('qtype_' . $this->qtype->name()); + } + + /** + * What data may be included in the form submission when a student submits + * this question in its current state? + * + * This information is used in calls to optional_param. The parameter name + * has {@link question_attempt::get_field_prefix()} automatically prepended. + * + * @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 abstract function get_expected_data(); + + /** + * What data would need to be submitted to get this question correct. + * If there is more than one correct answer, this method should just + * return one possibility. + * + * @return array parameter name => value. + */ + public abstract function get_correct_response(); + + /** + * Apply {@link format_text()} to some content with appropriate settings for + * this question. + * + * @param string $text some content that needs to be output. + * @param boolean $clean Whether the HTML needs to be cleaned. Generally, + * parts of the question do not need to be cleaned, and student input does. + * @return string the text formatted for output by format_text. + */ + public function format_text($text, $clean = false) { + $formatoptions = new stdClass; + $formatoptions->noclean = !$clean; + $formatoptions->para = false; + + return format_text($text, $this->questiontextformat, $formatoptions); + } + + /** @return the result of applying {@link format_text()} to the question text. */ + public function format_questiontext() { + return $this->format_text($this->questiontext); + } + + /** @return the result of applying {@link format_text()} to the general feedback. */ + public function format_generalfeedback() { + return $this->format_text($this->generalfeedback); + } +} + + +/** + * This class represents a 'question' that actually does not allow the student + * to respond, like the description 'question' type. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_information_item extends question_definition { + public function __construct() { + parent::__construct(); + $this->defaultmark = 0; + $this->penalty = 0; + $this->length = 0; + } + + public function make_behaviour(question_attempt $qa, $preferredbehaviour) { + question_engine::load_behaviour_class('informationitem'); + return new qbehaviour_informationitem($qa, $preferredbehaviour); + } + + public function get_expected_data() { + return array(); + } + + public function get_correct_response() { + return array(); + } + + public function get_question_summary() { + return null; + } +} + + +/** + * Interface that a {@link question_definition} must implement to be usable by + * the manual graded behaviour. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface question_manually_gradable { + /** + * Used by many of the behaviours, to work out whether the student's + * response to the question is complete. That is, whether the question attempt + * should move to the COMPLETE or INCOMPLETE state. + * + * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}. + * @return boolean whether this response is a complete answer to this question. + */ + public function is_complete_response(array $response); + + /** + * Use by many of the behaviours to determine whether the student's + * response has changed. This is normally used to determine that a new set + * of responses can safely be discarded. + * + * @param array $prevresponse the responses previously recorded for this question, + * as returned by {@link question_attempt_step::get_qt_data()} + * @param array $newresponse the new responses, in the same format. + * @return boolean whether the two sets of responses are the same - that is + * whether the new set of responses can safely be discarded. + */ + public function is_same_response(array $prevresponse, array $newresponse); + + /** + * Produce a plain text summary of a response. + * @param $response a response, as might be passed to {@link grade_response()}. + * @return string a plain text summary of that response, that could be used in reports. + */ + public function summarise_response(array $response); + + /** + * Categorise the student's response according to the categories defined by + * get_possible_responses. + * @param $response a response, as might be passed to {@link grade_response()}. + * @return array subpartid => {@link question_classified_response} objects. + * returns an empty array if no analysis is possible. + */ + public function classify_response(array $response); +} + + +/** + * This class is used in the return value from + * {@link question_manually_gradable::classify_response()}. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_classified_response { + /** + * @var string the classification of this response the student gave to this + * part of the question. Must match one of the responseclasses returned by + * {@link question_type::get_possible_responses()}. + */ + public $responseclassid; + /** @var string the actual response the student gave to this part. */ + public $response; + /** @var number the fraction this part of the response earned. */ + public $fraction; + /** + * Constructor, just an easy way to set the fields. + * @param string $responseclassid see the field descriptions above. + * @param string $response see the field descriptions above. + * @param number $fraction see the field descriptions above. + */ + public function __construct($responseclassid, $response, $fraction) { + $this->responseclassid = $responseclassid; + $this->response = $response; + $this->fraction = $fraction; + } + + public static function no_response() { + return new question_classified_response(null, null, null); + } +} + + +/** + * Interface that a {@link question_definition} must implement to be usable by + * the various automatic grading behaviours. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface question_automatically_gradable extends question_manually_gradable { + /** + * Use by many of the behaviours to determine whether the student + * has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}. + * @return boolean whether this response can be graded. + */ + public function is_gradable_response(array $response); + + /** + * In situations where is_gradable_response() returns false, this method + * should generate a description of what the problem is. + * @return string the message. + */ + public function get_validation_error(array $response); + + /** + * Grade a response to the question, returning a fraction between get_min_fraction() and 1.0, + * and the corresponding state CORRECT, PARTIALLY_CORRECT or INCORRECT. + * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}. + * @return array (number, integer) the fraction, and the state. + */ + public function grade_response(array $response); + + /** + * Get one of the question hints. The question_attempt is passed in case + * the question type wants to do something complex. For example, the + * multiple choice with multiple responses question type will turn off most + * of the hint options if the student has selected too many opitions. + * @param integer $hintnumber Which hint to display. Indexed starting from 0 + * @param question_attempt $qa The question_attempt. + */ + public function get_hint($hintnumber, question_attempt $qa); + + /** + * 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(); +} + + +/** + * Interface that a {@link question_definition} must implement to be usable by + * the interactivecountback behaviour. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface question_automatically_gradable_with_countback extends question_automatically_gradable { + /** + * Work out a final grade for this attempt, taking into account all the + * tries the student made. + * @param array $responses the response for each try. Each element of this + * array is a response array, as would be passed to {@link grade_response()}. + * There may be between 1 and $totaltries responses. + * @param integer $totaltries The maximum number of tries allowed. + * @return numeric the fraction that should be awarded for this + * sequence of response. + */ + public function compute_final_grade($responses, $totaltries); +} + + +/** + * This class represents a real question. That is, one that is not a + * {@link question_information_item}. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_with_responses extends question_definition + implements question_manually_gradable { + function classify_response(array $response) { + return array(); + } +} + + +/** + * This class represents a question that can be graded automatically. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_graded_automatically extends question_with_responses + implements question_automatically_gradable { + /** @var Some question types have the option to show the number of sub-parts correct. */ + public $shownumcorrect = false; + + public function is_gradable_response(array $response) { + return $this->is_complete_response($response); + } + + public function get_right_answer_summary() { + $correctresponse = $this->get_correct_response(); + if (empty($correctresponse)) { + return null; + } + return $this->summarise_response($correctresponse); + } + + public function get_hint($hintnumber, question_attempt $qa) { + if (!isset($this->hints[$hintnumber])) { + return null; + } + return $this->hints[$hintnumber]; + } +} + + +/** + * This class represents a question that can be graded automatically with + * countback grading in interactive mode. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_graded_automatically_with_countback + extends question_graded_automatically + implements question_automatically_gradable_with_countback { + + public function make_behaviour(question_attempt $qa, $preferredbehaviour) { + if ($preferredbehaviour == 'interactive') { + return question_engine::make_behaviour('interactivecountback', $qa, $preferredbehaviour); + } + return question_engine::make_archetypal_behaviour($preferredbehaviour, $qa); + } +} + + +/** + * This class represents a question that can be graded automatically by using + * a {@link question_grading_strategy}. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_graded_by_strategy extends question_graded_automatically { + /** @var question_grading_strategy the strategy to use for grading. */ + protected $gradingstrategy; + + /** @param question_grading_strategy $strategy the strategy to use for grading. */ + public function __construct(question_grading_strategy $strategy) { + parent::__construct(); + $this->gradingstrategy = $strategy; + } + + public function get_correct_response() { + $answer = $this->get_correct_answer(); + if (!$answer) { + return array(); + } + + return array('answer' => $answer->answer); + } + + /** + * Get an answer that contains the feedback and fraction that should be + * awarded for this resonse. + * @param array $response a response. + * @return question_answer the matching answer. + */ + public function get_matching_answer(array $response) { + return $this->gradingstrategy->grade($response); + } + + /** + * @return question_answer an answer that contains the a response that would + * get full marks. + */ + public function get_correct_answer() { + return $this->gradingstrategy->get_correct_answer(); + } + + public function grade_response(array $response) { + $answer = $this->get_matching_answer($response); + if ($answer) { + return array($answer->fraction, question_state::graded_state_for_fraction($answer->fraction)); + } else { + return array(0, question_state::$gradedwrong); + } + } + + public function classify_response(array $response) { + if (empty($response['answer'])) { + return array($this->id => question_classified_response::no_response()); + } + + $ans = $this->get_matching_answer($response); + if (!$ans) { + return array($this->id => question_classified_response::no_response()); + } + return array($this->id => new question_classified_response( + $ans->id, $response['answer'], $ans->fraction)); + } +} + + +/** + * Class to represent a question answer, loaded from the question_answers table + * in the database. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_answer { + /** @var string the answer. */ + public $answer; + + /** @var number the fraction this answer is worth. */ + public $fraction; + + /** @var string the feedback for this answer. */ + public $feedback; + + /** + * Constructor. + * @param string $answer the answer. + * @param number $fraction the fraction this answer is worth. + * @param string $feedback the feedback for this answer. + */ + public function __construct($answer, $fraction, $feedback) { + $this->answer = $answer; + $this->fraction = $fraction; + $this->feedback = $feedback; + } +} + + +/** + * Class to represent a hint associated with a question. + * Used by iteractive mode, etc. A question has an array of these. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_hint { + /** @var The feedback hint to be shown. */ + public $hint; + + /** + * Constructor. + * @param string $hint The hint text + */ + public function __construct($hint) { + $this->hint = $hint; + } + + /** + * Create a basic hint from a row loaded from the question_hints table in the database. + * @param object $row with $row->hint set. + * @return question_hint + */ + public static function load_from_record($row) { + return new question_hint($row->hint); + } + + /** + * Adjust this display options according to the hint settings. + * @param question_display_options $options + */ + public function adjust_display_options(question_display_options $options) { + // Do nothing. + } +} + + +/** + * An extension of {@link question_hint} for questions like match and multiple + * choice with multile answers, where there are options for whether to show the + * number of parts right at each stage, and to reset the wrong parts. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_hint_with_parts extends question_hint { + /** @var boolean option to show the number of sub-parts of the question that were right. */ + public $shownumcorrect; + + /** @var boolean option to clear the parts of the question that were wrong on retry. */ + public $clearwrong; + + /** + * Constructor. + * @param string $hint The hint text + * @param boolean $shownumcorrect whether the number of right parts should be shown + * @param boolean $clearwrong whether the wrong parts should be reset. + */ + public function __construct($hint, $shownumcorrect, $clearwrong) { + parent::__construct($hint); + $this->shownumcorrect = $shownumcorrect; + $this->clearwrong = $clearwrong; + } + + /** + * Create a basic hint from a row loaded from the question_hints table in the database. + * @param object $row with $row->hint, ->shownumcorrect and ->clearwrong set. + * @return question_hint_with_parts + */ + public static function load_from_record($row) { + return new question_hint_with_parts($row->hint, $row->shownumcorrect, $row->clearwrong); + } + + public function adjust_display_options(question_display_options $options) { + parent::adjust_display_options($options); + if ($this->clearwrong) { + $options->clearwrong = true; + } + $options->numpartscorrect = $this->shownumcorrect; + } +} + + +/** + * This question_grading_strategy interface. Used to share grading code between + * questions that that subclass {@link question_graded_by_strategy}. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface question_grading_strategy { + /** + * Return a question answer that describes the outcome (fraction and feeback) + * for a particular respons. + * @param array $response the response. + * @return question_answer the answer describing the outcome. + */ + public function grade(array $response); + + /** + * @return question_answer an answer that contains the a response that would + * get full marks. + */ + public function get_correct_answer(); +} + + +/** + * This interface defines the methods that a {@link question_definition} must + * implement if it is to be graded by the + * {@link question_first_matching_answer_grading_strategy}. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface question_response_answer_comparer { + /** @return array of {@link question_answers}. */ + public function get_answers(); + + /** + * @param array $response the response. + * @param question_answer $answer an answer. + * @return boolean whether the response matches the answer. + */ + public function compare_response_with_answer(array $response, question_answer $answer); +} + + +/** + * This grading strategy is used by question types like shortanswer an numerical. + * It gets a list of possible answers from the question, and returns the first one + * that matches the given response. It returns the first answer with fraction 1.0 + * when asked for the correct answer. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_first_matching_answer_grading_strategy implements question_grading_strategy { + /** + * @var question_response_answer_comparer (presumably also a + * {@link question_definition}) the question we are doing the grading for. + */ + protected $question; + + /** + * @param question_response_answer_comparer $question (presumably also a + * {@link question_definition}) the question we are doing the grading for. + */ + public function __construct(question_response_answer_comparer $question) { + $this->question = $question; + } + + public function grade(array $response) { + foreach ($this->question->get_answers() as $aid => $answer) { + if ($this->question->compare_response_with_answer($response, $answer)) { + $answer->id = $aid; + return $answer; + } + } + return null; + } + + public function get_correct_answer() { + foreach ($this->question->get_answers() as $answer) { + $state = question_state::graded_state_for_fraction($answer->fraction); + if ($state == question_state::$gradedright) { + return $answer; + } + } + return null; + } +} \ No newline at end of file diff --git a/question/type/questiontype.php b/question/type/questiontype.php index 5aaf4d323d8..d711416650d 100644 --- a/question/type/questiontype.php +++ b/question/type/questiontype.php @@ -1,20 +1,4 @@ -. - +libdir . '/questionlib.php'); +require_once($CFG->dirroot . '/question/engine/lib.php'); +// DONOTCOMMIT +class default_questiontype { + function plugin_dir() { + return ''; + } +} /** * This is the base class for Moodle question types. * @@ -45,79 +35,33 @@ require_once($CFG->libdir . '/questionlib.php'); * @package questionbank * @subpackage questiontypes */ -class default_questiontype { - protected $fileoptions = array( - 'subdirs' => false, - 'maxfiles' => -1, - 'maxbytes' => 0, - ); +class question_type { + + public function __construct() { + } /** - * Name of the question type - * - * The name returned should coincide with the name of the directory - * in which this questiontype is located - * * @return string the name of this question type. */ - function name() { - return 'default'; - } - - /** - * Returns a list of other question types that this one requires in order to - * work. For example, the calculated question type is a subclass of the - * numerical question type, which is a subclass of the shortanswer question - * type; and the randomsamatch question type requires the shortanswer type - * to be installed. - * - * @return array any other question types that this one relies on. An empty - * array if none. - */ - function requires_qtypes() { - return array(); - } - - /** - * @return string the name of this pluginfor passing to get_string, set/get_config, etc. - */ - function plugin_name() { - return 'qtype_' . $this->name(); - } - - /** - * @return string the name of this question type in the user's language. - * You should not need to override this method, the default behaviour should be fine. - */ - function local_name() { - return get_string($this->name(), $this->plugin_name()); + public function name() { + return substr(get_class($this), 6); } /** * The name this question should appear as in the create new question - * dropdown. Override this method to return false if you don't want your - * question type to be createable, for example if it is an abstract base type, - * otherwise, you should not need to override this method. + * dropdown. * * @return mixed the desired string, or false to hide this question type in the menu. */ - function menu_name() { - return $this->local_name(); + public function menu_name() { + $name = $this->name(); + return get_string($name, 'qtype_' . $name); } /** - * @return boolean override this to return false if this is not really a - * question type, for example the description question type is not - * really a question type. + * @return boolean true if this question type sometimes requires manual grading. */ - function is_real_question_type() { - return true; - } - - /** - * @return boolean true if this question type may require manual grading. - */ - function is_manual_graded() { + public function is_manual_graded() { return false; } @@ -126,35 +70,38 @@ class default_questiontype { * @param string $otherquestionsinuse comma-separate list of other question ids in this attempt. * @return boolean true if a particular instance of this question requires manual grading. */ - function is_question_manual_graded($question, $otherquestionsinuse) { + public function is_question_manual_graded($question, $otherquestionsinuse) { return $this->is_manual_graded(); } - /** - * @return boolean true if a table analyzing responses should be shown in - * the quiz statistics report. Usually if a question is manually graded - * then this analysis table won't be a good idea. - */ - function show_analysis_of_responses() { - return !$this->is_manual_graded(); - } - /** * @return boolean true if this question type can be used by the random question type. */ - function is_usable_by_random() { + public function is_usable_by_random() { return true; } /** - * @param question record. - * @param integer subqid this is the id of the subquestion. Usually the id - * of the question record of the question record but this is dependent on - * the question type. Not relevant to some question types. - * @return whether the teacher supplied responses can include wildcards. Can - * more than one answer be equivalent to one teacher supplied response. + * Whether this question type can perform a frequency analysis of student + * responses. + * + * If this method returns true, you must implement the get_possible_responses + * method, and the question_definition class must implement the + * classify_response method. + * + * @return boolean whether this report can analyse all the student reponses + * for things like the quiz statistics report. */ - function has_wildcards_in_responses($question, $subqid) { + public function can_analyse_responses() { + // This works in most cases. + return !$this->is_manual_graded(); + } + + /** + * @return whether the question_answers.answer field needs to have + * restore_decode_content_links_worker called on it. + */ + public function has_html_answers() { return false; } @@ -166,7 +113,7 @@ class default_questiontype { * * @return mixed array as above, or null to tell the base class to do nothing. */ - function extra_question_fields() { + public function extra_question_fields() { return null; } @@ -174,7 +121,7 @@ class default_questiontype { * If you use extra_question_fields, overload this function to return question id field name * in case you table use another name for this column */ - function questionid_column_name() { + protected function questionid_column_name() { return 'questionid'; } @@ -185,20 +132,20 @@ class default_questiontype { * * @return mixed array as above, or null to tell the base class to do nothing. */ - function extra_answer_fields() { - return null; - } + protected function extra_answer_fields() { + return null; + } /** * Return an instance of the question editing form definition. This looks for a * class called edit_{$this->name()}_question_form in the file - * {$CFG->dirroot}/question/type/{$this->name()}/edit_{$this->name()}_question_form.php + * {$CFG->docroot}/question/type/{$this->name()}/edit_{$this->name()}_question_form.php * and if it exists returns an instance of it. * * @param string $submiturl passed on to the constructor call. * @return object an instance of the form definition, or null if one could not be found. */ - function create_editing_form($submiturl, $question, $category, $contexts, $formeditable) { + public function create_editing_form($submiturl, $question, $category, $contexts, $formeditable) { global $CFG; require_once("{$CFG->dirroot}/question/type/edit_question_form.php"); $definition_file = $CFG->dirroot.'/question/type/'.$this->name().'/edit_'.$this->name().'_form.php'; @@ -216,7 +163,7 @@ class default_questiontype { /** * @return string the full path of the folder this plugin's files live in. */ - function plugin_dir() { + public function plugin_dir() { global $CFG; return $CFG->dirroot . '/question/type/' . $this->name(); } @@ -224,7 +171,7 @@ class default_questiontype { /** * @return string the URL of the folder this plugin's files live in. */ - function plugin_baseurl() { + public function plugin_baseurl() { global $CFG; return $CFG->wwwroot . '/question/type/' . $this->name(); } @@ -237,12 +184,9 @@ class default_questiontype { * @param object $question * @param string $wizardnow is '' for first page. */ - function display_question_editing_page(&$mform, $question, $wizardnow){ - global $OUTPUT; - $heading = $this->get_heading(empty($question->id)); - - echo $OUTPUT->heading_with_help($heading, $this->name(), $this->plugin_name()); - + public function display_question_editing_page(&$mform, $question, $wizardnow) { + $name = $this->name(); + print_heading_with_help($this->get_heading(empty($question->id)), $name, 'qtype_' . $name); $permissionstrs = array(); if (!empty($question->id)){ if ($question->formoptions->canedit){ @@ -256,13 +200,13 @@ class default_questiontype { } } if (!$question->formoptions->movecontext && count($permissionstrs)){ - echo $OUTPUT->heading(get_string('permissionto', 'question'), 3); + print_heading(get_string('permissionto', 'question'), 'center', 3); $html = '
    '; foreach ($permissionstrs as $permissionstr){ $html .= '
  • '.$permissionstr.'
  • '; } $html .= '
'; - echo $OUTPUT->box($html, 'boxwidthnarrow boxaligncenter generalbox'); + print_box($html, 'boxwidthnarrow boxaligncenter generalbox'); } $mform->display(); } @@ -270,15 +214,27 @@ class default_questiontype { /** * Method called by display_question_editing_page and by question.php to get heading for breadcrumbs. * - * @return array a string heading and the langmodule in which it was found. + * @return string the heading */ - function get_heading($adding = false){ - if ($adding) { - $prefix = 'adding'; + public function get_heading($adding = false){ + $name = $this->name(); + if ($adding){ + $action = 'adding'; } else { - $prefix = 'editing'; + $action = 'editing'; } - return get_string($prefix . $this->name(), $this->plugin_name()); + return get_string($action . $name, 'qtype_' . $name); + } + + /** + * Set any missing settings for this question to the default values. This is + * called before displaying the question editing form. + * + * @param object $questiondata the question data, loaded from the databsae, + * or more likely a newly created question object that is only partially + * initialised. + */ + public function set_default_options($questiondata) { } /** @@ -305,37 +261,34 @@ class default_questiontype { * redisplayed with validation errors, from validation_errors field, which * is itself an object, shown next to the form fields. (I don't think this is accurate any more.) */ - function save_question($question, $form) { - global $USER, $DB, $OUTPUT; - - list($question->category) = explode(',', $form->category); - $context = $this->get_context_by_category_id($question->category); + public function save_question($question, $form, $course) { + global $USER; // This default implementation is suitable for most // question types. // First, save the basic question itself $question->name = trim($form->name); + $question->questiontext = trim($form->questiontext); + $question->questiontextformat = $form->questiontextformat; $question->parent = isset($form->parent) ? $form->parent : 0; $question->length = $this->actual_number_of_questions($question); $question->penalty = isset($form->penalty) ? $form->penalty : 0; - if (empty($form->questiontext['text'])) { - $question->questiontext = ''; + if (empty($form->image)) { + $question->image = ''; } else { - $question->questiontext = trim($form->questiontext['text']);; + $question->image = $form->image; } - $question->questiontextformat = !empty($form->questiontext['format'])?$form->questiontext['format']:0; - if (empty($form->generalfeedback['text'])) { + if (empty($form->generalfeedback)) { $question->generalfeedback = ''; } else { - $question->generalfeedback = trim($form->generalfeedback['text']); + $question->generalfeedback = trim($form->generalfeedback); } - $question->generalfeedbackformat = !empty($form->generalfeedback['format'])?$form->generalfeedback['format']:0; if (empty($question->name)) { - $question->name = shorten_text(strip_tags($form->questiontext['text']), 15); + $question->name = shorten_text(strip_tags($question->questiontext), 15); if (empty($question->name)) { $question->name = '-'; } @@ -345,46 +298,43 @@ class default_questiontype { $question->errors['penalty'] = get_string('invalidpenalty', 'quiz'); } - if (isset($form->defaultgrade)) { - $question->defaultgrade = $form->defaultgrade; + if (isset($form->defaultmark)) { + $question->defaultmark = $form->defaultmark; } - // If the question is new, create it. - if (empty($question->id)) { + list($question->category) = explode(',', $form->category); + + if (!empty($question->id)) { + /// Question already exists, update. + $question->modifiedby = $USER->id; + $question->timemodified = time(); + if (!update_record('question', $question)) { + error('Could not update question!'); + } + + } else { + /// New question. // Set the unique code $question->stamp = make_unique_id_code(); $question->createdby = $USER->id; + $question->modifiedby = $USER->id; $question->timecreated = time(); - $question->id = $DB->insert_record('question', $question); + $question->timemodified = time(); + if (!$question->id = insert_record('question', $question)) { + error('Could not insert new question!'); + } } - // Now, whether we are updating a existing question, or creating a new - // one, we have to do the files processing and update the record. - /// Question already exists, update. - $question->modifiedby = $USER->id; - $question->timemodified = time(); - - if (!empty($question->questiontext) && !empty($form->questiontext['itemid'])) { - $question->questiontext = file_save_draft_area_files($form->questiontext['itemid'], $context->id, 'question', 'questiontext', (int)$question->id, $this->fileoptions, $question->questiontext); - } - if (!empty($question->generalfeedback) && !empty($form->generalfeedback['itemid'])) { - $question->generalfeedback = file_save_draft_area_files($form->generalfeedback['itemid'], $context->id, 'question', 'generalfeedback', (int)$question->id, $this->fileoptions, $question->generalfeedback); - } - $DB->update_record('question', $question); - // Now to save all the answers and type-specific options $form->id = $question->id; $form->qtype = $question->qtype; $form->category = $question->category; $form->questiontext = $question->questiontext; - $form->questiontextformat = $question->questiontextformat; - // current context - $form->context = $context; $result = $this->save_question_options($form); if (!empty($result->error)) { - print_error($result->error); + error($result->error); } if (!empty($result->notice)) { @@ -392,25 +342,29 @@ class default_questiontype { } if (!empty($result->noticeyesno)) { - throw new coding_exception('$result->noticeyesno no longer supported in save_question.'); + notice_yesno($result->noticeyesno, "question.php?id=$question->id&courseid={$course->id}", + "edit.php?courseid={$course->id}"); + print_footer($course); + exit; } // Give the question a unique version stamp determined by question_hash() - $DB->set_field('question', 'version', question_hash($question), array('id' => $question->id)); + if (!set_field('question', 'version', question_hash($question), 'id', $question->id)) { + error('Could not update question version field'); + } return $question; } /** - * Saves question-type specific options - * - * This is called by {@link save_question()} to save the question-type specific data - * @return object $result->error or $result->noticeyesno or $result->notice - * @param object $question This holds the information from the editing form, - * it is not a standard question object. - */ - function save_question_options($question) { - global $DB; + * Saves question-type specific options + * + * This is called by {@link save_question()} to save the question-type specific data + * @return object $result->error or $result->noticeyesno or $result->notice + * @param object $question This holds the information from the editing form, + * it is not a standard question object. + */ + public function save_question_options($question) { $extra_question_fields = $this->extra_question_fields(); if (is_array($extra_question_fields)) { @@ -418,7 +372,7 @@ class default_questiontype { $function = 'update_record'; $questionidcolname = $this->questionid_column_name(); - $options = $DB->get_record($question_extension_table, array($questionidcolname => $question->id)); + $options = get_record($question_extension_table, $questionidcolname, $question->id); if (!$options) { $function = 'insert_record'; $options = new stdClass; @@ -434,7 +388,7 @@ class default_questiontype { $options->$field = $question->$field; } - if (!$DB->{$function}($question_extension_table, $options)) { + if (!$function($question_extension_table, $options)) { $result = new stdClass; $result->error = 'Could not save question options for ' . $this->name() . ' question id ' . $question->id; @@ -448,35 +402,76 @@ class default_questiontype { return null; } - /** - * Loads the question type specific options for the question. - * - * This function loads any question type specific options for the - * question from the database into the question object. This information - * is placed in the $question->options field. A question type is - * free, however, to decide on a internal structure of the options field. - * @return bool Indicates success or failure. - * @param object $question The question object for the question. This object - * should be updated to include the question type - * specific information (it is passed by reference). - */ - function get_question_options(&$question) { - global $CFG, $DB, $OUTPUT; + public function save_hints($formdata, $withparts = false) { + delete_records('question_hints', 'questionid', $formdata->id); - if (!isset($question->options)) { - $question->options = new stdClass(); + if (!empty($formdata->hint)) { + $numhints = max(array_keys($formdata->hint)) + 1; + } else { + $numhints = 0; } + if ($withparts) { + if (!empty($formdata->hintclearwrong)) { + $numclears = max(array_keys($formdata->hintclearwrong)) + 1; + } else { + $numclears = 0; + } + if (!empty($formdata->hintshownumcorrect)) { + $numshows = max(array_keys($formdata->hintshownumcorrect)) + 1; + } else { + $numshows = 0; + } + $numhints = max($numhints, $numclears, $numshows); + } + + for ($i = 0; $i < $numhints; $i += 1) { + $hint = new stdClass; + $hint->hint = $formdata->hint[$i]; + $hint->questionid = $formdata->id; + + if (html_is_blank($hint->hint)) { + $hint->hint = ''; + } + + if ($withparts) { + $hint->clearwrong = !empty($formdata->hintclearwrong[$i]); + $hint->shownumcorrect = !empty($formdata->hintshownumcorrect[$i]); + } + + if (empty($hint->hint) && empty($hint->clearwrong) && empty($hint->shownumcorrect)) { + continue; + } + + insert_record('question_hints', $hint); + } + } + + /** + * Loads the question type specific options for the question. + * + * This function loads any question type specific options for the + * question from the database into the question object. This information + * is placed in the $question->options field. A question type is + * free, however, to decide on a internal structure of the options field. + * @return bool Indicates success or failure. + * @param object $question The question object for the question. This object + * should be updated to include the question type + * specific information (it is passed by reference). + */ + public function get_question_options($question) { + global $CFG; + $extra_question_fields = $this->extra_question_fields(); if (is_array($extra_question_fields)) { $question_extension_table = array_shift($extra_question_fields); - $extra_data = $DB->get_record($question_extension_table, array($this->questionid_column_name() => $question->id), implode(', ', $extra_question_fields)); + $extra_data = get_record($question_extension_table, $this->questionid_column_name(), $question->id, '', '', '', '', implode(', ', $extra_question_fields)); if ($extra_data) { foreach ($extra_question_fields as $field) { $question->options->$field = $extra_data->$field; } } else { - echo $OUTPUT->notification("Failed to load question options from the table $question_extension_table for questionid " . + notify("Failed to load question options from the table $question_extension_table for questionid " . $question->id); return false; } @@ -485,60 +480,146 @@ class default_questiontype { $extra_answer_fields = $this->extra_answer_fields(); if (is_array($extra_answer_fields)) { $answer_extension_table = array_shift($extra_answer_fields); - $question->options->answers = $DB->get_records_sql(" - SELECT qa.*, qax." . implode(', qax.', $extra_answer_fields) . " - FROM {question_answers} qa, {$answer_extension_table} qax - WHERE qa.questionid = ? AND qax.answerid = qa.id", array($question->id)); + $question->options->answers = get_records_sql(' + SELECT qa.*, qax.' . implode(', qax.', $extra_answer_fields) . ' + FROM ' . $CFG->prefix . 'question_answers qa, ' . $CFG->prefix . '$answer_extension_table qax + WHERE qa.questionid = ' . $question->id . ' AND qax.answerid = qa.id'); if (!$question->options->answers) { - echo $OUTPUT->notification("Failed to load question answers from the table $answer_extension_table for questionid " . + notify("Failed to load question answers from the table $answer_extension_table for questionid " . $question->id); return false; } } else { // Don't check for success or failure because some question types do not use the answers table. - $question->options->answers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC'); + $question->options->answers = get_records('question_answers', 'question', $question->id, 'id ASC'); } - return true; - } - - /** - * Deletes states from the question-type specific tables - * - * @param string $stateslist Comma separated list of state ids to be deleted - */ - function delete_states($stateslist) { - /// The default question type does not have any tables of its own - // therefore there is nothing to delete + $question->hints = get_records('question_hints', 'questionid', $question->id, 'id ASC'); return true; } /** - * Deletes the question-type specific data when a question is deleted. - * @param integer $question the question being deleted. - * @param integer $contextid the context this quesiotn belongs to. + * Create an appropriate question_definition for the question of this type + * using data loaded from the database. + * @param object $questiondata the question data loaded from the database. + * @return question_definition the corresponding question_definition. */ - function delete_question($questionid, $contextid) { - global $DB; + public function make_question($questiondata) { + $question = $this->make_question_instance($questiondata); + $this->initialise_question_instance($question, $questiondata); + return $question; + } - $this->delete_files($questionid, $contextid); + /** + * Create an appropriate question_definition for the question of this type + * using data loaded from the database. + * @param object $questiondata the question data loaded from the database. + * @return question_definition an instance of the appropriate question_definition subclass. + * Still needs to be initialised. + */ + protected function make_question_instance($questiondata) { + question_bank::load_question_definition_classes($this->name()); + $class = 'qtype_' . $this->name() . '_question'; + return new $class(); + } + + /** + * Initialise the common question_definition fields. + * @param question_definition $question the question_definition we are creating. + * @param object $questiondata the question data loaded from the database. + */ + protected function initialise_question_instance(question_definition $question, $questiondata) { + $question->id = $questiondata->id; + $question->category = $questiondata->category; + $question->parent = $questiondata->parent; + $question->qtype = $this; + $question->name = $questiondata->name; + $question->questiontext = $questiondata->questiontext; + $question->questiontextformat = $questiondata->questiontextformat; + $question->generalfeedback = $questiondata->generalfeedback; + $question->defaultmark = $questiondata->defaultmark + 0; + $question->length = $questiondata->length; + $question->penalty = $questiondata->penalty; + $question->stamp = $questiondata->stamp; + $question->version = $questiondata->version; + $question->hidden = $questiondata->hidden; + $question->timecreated = $questiondata->timecreated; + $question->timemodified = $questiondata->timemodified; + $question->createdby = $questiondata->createdby; + $question->modifiedby = $questiondata->modifiedby; + + $this->initialise_question_hints($question, $questiondata); + } + + /** + * Initialise question_definition::hints field. + * @param question_definition $question the question_definition we are creating. + * @param object $questiondata the question data loaded from the database. + */ + protected function initialise_question_hints(question_definition $question, $questiondata) { + if (empty($questiondata->hints)) { + return; + } + foreach ($questiondata->hints as $hint) { + $question->hints[] = $this->make_hint($hint); + } + } + + /** + * Create a question_hint, or an appropriate subclass for this question, + * from a row loaded from the database. + * @param object $hint the DB row from the question hints table. + * @return question_hint + */ + protected function make_hint($hint) { + return question_hint::load_from_record($hint); + } + + /** + * Initialise question_definition::answers field. + * @param question_definition $question the question_definition we are creating. + * @param object $questiondata the question data loaded from the database. + */ + protected function initialise_question_answers(question_definition $question, $questiondata) { + $question->answers = array(); + if (empty($questiondata->options->answers)) { + return; + } + foreach ($questiondata->options->answers as $a) { + $question->answers[$a->id] = new question_answer($a->answer, $a->fraction, $a->feedback); + } + } + + /** + * Deletes a question from the question-type specific tables + * + * @return boolean Success/Failure + * @param object $question The question being deleted + */ + public function delete_question($questionid) { + global $CFG; + $success = true; $extra_question_fields = $this->extra_question_fields(); if (is_array($extra_question_fields)) { $question_extension_table = array_shift($extra_question_fields); - $DB->delete_records($question_extension_table, - array($this->questionid_column_name() => $questionid)); + $success = $success && delete_records($question_extension_table, + $this->questionid_column_name(), $questionid); } $extra_answer_fields = $this->extra_answer_fields(); if (is_array($extra_answer_fields)) { $answer_extension_table = array_shift($extra_answer_fields); - $DB->delete_records_select($answer_extension_table, - "answerid IN (SELECT qa.id FROM {question_answers} qa WHERE qa.question = ?)", array($questionid)); + $success = $success && delete_records_select($answer_extension_table, + "answerid IN (SELECT qa.id FROM {$CFG->prefix}question_answers qa WHERE qa.question = $questionid)"); } - $DB->delete_records('question_answers', array('question' => $questionid)); + $success = $success && delete_records('question_answers', 'question', $questionid); + + $success = $success && delete_records('question_hints', 'questionid', $questionid); + + return $success; } /** @@ -554,930 +635,99 @@ class default_questiontype { * @param object $question The question whose length is to be determined. * Question type specific information is included. */ - function actual_number_of_questions($question) { + public function actual_number_of_questions($question) { // By default, each question is given one number return 1; } - /** - * Creates empty session and response information for the question - * - * This function is called to start a question session. Empty question type - * specific session data (if any) and empty response data will be added to the - * state object. Session data is any data which must persist throughout the - * attempt possibly with updates as the user interacts with the - * question. This function does NOT create new entries in the database for - * the session; a call to the {@link save_session_and_responses} member will - * occur to do this. - * @return bool Indicates success or failure. - * @param object $question The question for which the session is to be - * created. Question type specific information is - * included. - * @param object $state The state to create the session for. Note that - * this will not have been saved in the database so - * there will be no id. This object will be updated - * to include the question type specific information - * (it is passed by reference). In particular, empty - * responses will be created in the ->responses - * field. - * @param object $cmoptions - * @param object $attempt The attempt for which the session is to be - * started. Questions may wish to initialize the - * session in different ways depending on the user id - * or time available for the attempt. - */ - function create_session_and_responses(&$question, &$state, $cmoptions, $attempt) { - // The default implementation should work for the legacy question types. - // Most question types with only a single form field for the student's response - // will use the empty string '' as the index for that one response. This will - // automatically be stored in and restored from the answer field in the - // question_states table. - $state->responses = array( - '' => '', - ); - return true; - } - - /** - * Restores the session data and most recent responses for the given state - * - * This function loads any session data associated with the question - * session in the given state from the database into the state object. - * In particular it loads the responses that have been saved for the given - * state into the ->responses member of the state object. - * - * Question types with only a single form field for the student's response - * will not need not restore the responses; the value of the answer - * field in the question_states table is restored to ->responses[''] - * before this function is called. Question types with more response fields - * should override this method and set the ->responses field to an - * associative array of responses. - * @return bool Indicates success or failure. - * @param object $question The question object for the question including any - * question type specific information. - * @param object $state The saved state to load the session for. This - * object should be updated to include the question - * type specific session information and responses - * (it is passed by reference). - */ - function restore_session_and_responses(&$question, &$state) { - // The default implementation does nothing (successfully) - return true; - } - - /** - * Saves the session data and responses for the given question and state - * - * This function saves the question type specific session data from the - * state object to the database. In particular for most question types it saves the - * responses from the ->responses member of the state object. The question type - * non-specific data for the state has already been saved in the question_states - * table and the state object contains the corresponding id and - * sequence number which may be used to index a question type specific table. - * - * Question types with only a single form field for the student's response - * which is contained in ->responses[''] will not have to save this response, - * it will already have been saved to the answer field of the question_states table. - * Question types with more response fields should override this method to convert - * the data the ->responses array into a single string field, and save it in the - * database. The implementation in the multichoice question type is a good model to follow. - * http://cvs.moodle.org/contrib/plugins/question/type/opaque/questiontype.php?view=markup - * has a solution that is probably quite generally applicable. - * @return bool Indicates success or failure. - * @param object $question The question object for the question including - * the question type specific information. - * @param object $state The state for which the question type specific - * data and responses should be saved. - */ - function save_session_and_responses(&$question, &$state) { - // The default implementation does nothing (successfully) - return true; - } - - /** - * Returns an array of values which will give full marks if graded as - * the $state->responses field - * - * The correct answer to the question in the given state, or an example of - * a correct answer if there are many, is returned. This is used by some question - * types in the {@link grade_responses()} function but it is also used by the - * question preview screen to fill in correct responses. - * @return mixed A response array giving the responses corresponding - * to the (or a) correct answer to the question. If there is - * no correct answer that scores 100% then null is returned. - * @param object $question The question for which the correct answer is to - * be retrieved. Question type specific information is - * available. - * @param object $state The state of the question, for which a correct answer is - * needed. Question type specific information is included. - */ - function get_correct_responses(&$question, &$state) { - /* The default implementation returns the response for the first answer - that gives full marks. */ - if ($question->options->answers) { - foreach ($question->options->answers as $answer) { - if (((int) $answer->fraction) === 1) { - return array('' => $answer->answer); - } - } - } - return null; - } - - /** - * Return an array of values with the texts for all possible responses stored - * for the question - * - * All answers are found and their text values isolated - * @return object A mixed object - * ->id question id. Needed to manage random questions: - * it's the id of the actual question presented to user in a given attempt - * ->responses An array of values giving the responses corresponding - * to all answers to the question. Answer ids are used as keys. - * The text and partial credit are the object components - * @param object $question The question for which the answers are to - * be retrieved. Question type specific information is - * available. - */ - // ULPGC ecastro - function get_all_responses(&$question, &$state) { - if (isset($question->options->answers) && is_array($question->options->answers)) { - $answers = array(); - foreach ($question->options->answers as $aid=>$answer) { - $r = new stdClass; - $r->answer = $answer->answer; - $r->credit = $answer->fraction; - $answers[$aid] = $r; - } - $result = new stdClass; - $result->id = $question->id; - $result->responses = $answers; - return $result; - } else { - return null; - } - } - /** - * The difference between this method an get_all_responses is that this - * method is not passed a state object. It is the possible answers to a - * question no matter what the state. - * This method is not called for random questions. - * @return array of possible answers. - */ - function get_possible_responses(&$question) { - static $responses = array(); - if (!isset($responses[$question->id])){ - $responses[$question->id] = $this->get_all_responses($question, new object()); - } - return array($question->id => $responses[$question->id]->responses); - } - /** * @param object $question - * @return mixed either a integer score out of 1 that the average random - * guess by a student might give or an empty string which means will not - * calculate. + * @return number|null either a fraction estimating what the student would + * score by guessing, or null, if it is not possible to estimate. */ - function get_random_guess_score($question) { + function get_random_guess_score($questiondata) { return 0; } - /** - * Return the actual response to the question in a given state - * for the question. Text is not yet formatted for output. - * - * @return mixed An array containing the response or reponses (multiple answer, match) - * given by the user in a particular attempt. - * @param object $question The question for which the correct answer is to - * be retrieved. Question type specific information is - * available. - * @param object $state The state object that corresponds to the question, - * for which a correct answer is needed. Question - * type specific information is included. - */ - // ULPGC ecastro - function get_actual_response($question, $state) { - if (!empty($state->responses)) { - $responses[] = $state->responses['']; - } else { - $responses[] = ''; - } - return $responses; - } - - function get_actual_response_details($question, $state) { - $response = array_shift($this->get_actual_response($question, $state)); - $teacherresponses = $this->get_possible_responses($question, $state); - //only one response - list($tsubqid, $tresponses) = each($teacherresponses); - $responsedetail = new stdClass(); - $responsedetail->subqid = $tsubqid; - $responsedetail->response = $response; - if ($aid = $this->check_response($question, $state)){ - $responsedetail->aid = $aid; - } else { - foreach ($tresponses as $aid => $tresponse){ - if ($tresponse->answer == $response){ - $responsedetail->aid = $aid; - break; - } - } - } - if (isset($responsedetail->aid)){ - $responsedetail->credit = $tresponses[$aid]->credit; - } else { - $responsedetail->aid = 0; - $responsedetail->credit = 0; - } - return array($responsedetail); - } - - // ULPGC ecastro - function get_fractional_grade(&$question, &$state) { - $grade = $state->grade; - if ($question->maxgrade > 0) { - return (float)($grade / $question->maxgrade); - } else { - return (float)$grade; - } - } - /** - * Checks if the response given is correct and returns the id - * - * @return int The ide number for the stored answer that matches the response - * given by the user in a particular attempt. - * @param object $question The question for which the correct answer is to - * be retrieved. Question type specific information is - * available. - * @param object $state The state object that corresponds to the question, - * for which a correct answer is needed. Question - * type specific information is included. - */ - // ULPGC ecastro - function check_response(&$question, &$state){ - return false; - } - - // Used by the following function, so that it only returns results once per quiz page. - private $htmlheadalreadydone = false; - /** - * Hook to allow question types to include required JavaScrip or CSS on pages - * where they are going to be printed. + * This method should return all the possible types of response that are + * recognised for this question. * - * If this question type requires JavaScript to function, - * then this method, which will be called before print_header on any page - * where this question is going to be printed, is a chance to call - * $PAGE->requires->js, and so on. + * The question is modelled as comprising one or more subparts. For each + * subpart, there are one or more classes that that students response + * might fall into, each of those classes earning a certain score. * - * The two parameters match the first two parameters of print_question. + * For example, in a shortanswer question, there is only one subpart, the + * text entry field. The response the student gave will be classified according + * to which of the possible $question->options->answers it matches. * - * @param object $question The question object. - * @param object $state The state object. + * For the matching question type, there will be one subpart for each + * question stem, and for each stem, each of the possible choices is a class + * of student's response. + * + * A response is an object with two fields, ->responseclass is a string + * presentation of that response, and ->fraction, the credit for a response + * in that class. + * + * Array keys have no specific meaning, but must be unique, and must be + * the same if this function is called repeatedly. + * + * @param object $question the question definition data. + * @return array keys are subquestionid, values are arrays of possible + * responses to that subquestion. */ - function get_html_head_contributions(&$question, &$state) { - // We only do this once for this question type, no matter how often this - // method is called on one page. - if ($this->htmlheadalreadydone) { - return; - } - $this->htmlheadalreadydone = true; - - // By default, we link to any of the files script.js or script.php that - // exist in the plugin folder. - $this->find_standard_scripts(); + function get_possible_responses($questiondata) { + return array(); } /** - * Like @see{get_html_head_contributions}, but this method is for CSS and - * JavaScript required on the question editing page question/question.php. + * Return any CSS JavaScript required on the head of the question editing + * page question/question.php. + * + * @return an array of bits of HTML to add to the head of pages where + * this question is displayed in the body. The array should use + * integer array keys, which have no significance. */ - function get_editing_head_contributions() { + public function get_editing_head_contributions() { // By default, we link to any of the files styles.css, styles.php, // script.js or script.php that exist in the plugin folder. // Core question types should not use this mechanism. Their styles // should be included in the standard theme. - $this->find_standard_scripts(); + return $this->find_standard_scripts_and_css(); } /** - * Utility method used by @see{get_html_head_contributions} and + * Utility method used by @see{get_editing_head_contributions} and * @see{get_editing_head_contributions}. This looks for any of the files - * script.js or script.php that exist in the plugin folder and ensures they - * get included. + * styles.css, styles.php, script.js or script.php that exist in the plugin + * folder and ensures they get included. + * + * @return array as required by get_editing_head_contributions. */ - protected function find_standard_scripts() { - global $PAGE; - + public function find_standard_scripts_and_css() { $plugindir = $this->plugin_dir(); - $plugindirrel = 'question/type/' . $this->name(); + $baseurl = $this->plugin_baseurl(); if (file_exists($plugindir . '/script.js')) { - $PAGE->requires->js('/' . $plugindirrel . '/script.js'); + require_js($baseurl . '/script.js'); } if (file_exists($plugindir . '/script.php')) { - $PAGE->requires->js('/' . $plugindirrel . '/script.php'); - } - } - - /** - * Prints the question including the number, grading details, content, - * feedback and interactions - * - * This function prints the question including the question number, - * grading details, content for the question, any feedback for the previously - * submitted responses and the interactions. The default implementation calls - * various other methods to print each of these parts and most question types - * will just override those methods. - * @param object $question The question to be rendered. Question type - * specific information is included. The - * maximum possible grade is in ->maxgrade. The name - * prefix for any named elements is in ->name_prefix. - * @param object $state The state to render the question in. The grading - * information is in ->grade, ->raw_grade and - * ->penalty. The current responses are in - * ->responses. This is an associative array (or the - * empty string or null in the case of no responses - * submitted). The last graded state is in - * ->last_graded (hence the most recently graded - * responses are in ->last_graded->responses). The - * question type specific information is also - * included. - * @param integer $number The number for this question. - * @param object $cmoptions - * @param object $options An object describing the rendering options. - */ - function print_question(&$question, &$state, $number, $cmoptions, $options, $context=null) { - /* The default implementation should work for most question types - provided the member functions it calls are overridden where required. - The layout is determined by the template question.html */ - - global $CFG, $OUTPUT; - - $context = $this->get_context_by_category_id($question->category); - $question->questiontext = quiz_rewrite_question_urls($question->questiontext, 'pluginfile.php', $context->id, 'question', 'questiontext', array($state->attempt, $state->question), $question->id); - - $question->generalfeedback = quiz_rewrite_question_urls($question->generalfeedback, 'pluginfile.php', $context->id, 'question', 'generalfeedback', array($state->attempt, $state->question), $question->id); - - $isgraded = question_state_is_graded($state->last_graded); - - if (isset($question->randomquestionid)) { - $actualquestionid = $question->randomquestionid; - } else { - $actualquestionid = $question->id; + require_js($baseurl . '/script.php'); } - // For editing teachers print a link to an editing popup window - $editlink = $this->get_question_edit_link($question, $cmoptions, $options); - - $generalfeedback = ''; - if ($isgraded && $options->generalfeedback) { - $generalfeedback = $this->format_text($question->generalfeedback, - $question->generalfeedbackformat, $cmoptions); + $stylesheets = array(); + if (file_exists($plugindir . '/styles.css')) { + $stylesheets[] = 'styles.css'; } - - $grade = ''; - if ($question->maxgrade > 0 && $options->scores) { - if ($cmoptions->optionflags & QUESTION_ADAPTIVE) { - if ($isgraded) { - $grade = question_format_grade($cmoptions, $state->last_graded->grade).'/'; - } else { - $grade = '--/'; - } - } - $grade .= question_format_grade($cmoptions, $question->maxgrade); + if (file_exists($plugindir . '/styles.php')) { + $stylesheets[] = 'styles.php'; } - - $formatoptions = new stdClass; - $formatoptions->para = false; - $comment = format_text($state->manualcomment, $state->manualcommentformat, - $formatoptions, $cmoptions->course); - $commentlink = ''; - - if (!empty($options->questioncommentlink)) { - $strcomment = get_string('commentorgrade', 'quiz'); - - $link = new moodle_url("$options->questioncommentlink?attempt=$state->attempt&question=$actualquestionid"); - $action = new popup_action('click', $link, 'commentquestion', array('height' => 480, 'width' => 750)); - $commentlink = $OUTPUT->container($OUTPUT->action_link($link, $strcomment, $action), 'commentlink'); + $contributions = array(); + foreach ($stylesheets as $stylesheet) { + $contributions[] = ''; } - - $history = $this->history($question, $state, $number, $cmoptions, $options); - - include "$CFG->dirroot/question/type/question.html"; - } - - /** - * Render the question flag, assuming $flagsoption allows it. You will probably - * never need to override this method. - * - * @param object $question the question - * @param object $state its current state - * @param integer $flagsoption the option that says whether flags should be displayed. - */ - protected function print_question_flag($question, $state, $flagsoption) { - global $CFG, $PAGE; - switch ($flagsoption) { - case QUESTION_FLAGSSHOWN: - $flagcontent = $this->get_question_flag_tag($state->flagged); - break; - case QUESTION_FLAGSEDITABLE: - $id = $question->name_prefix . '_flagged'; - if ($state->flagged) { - $checked = 'checked="checked" '; - } else { - $checked = ''; - } - $qsid = $state->questionsessionid; - $aid = $state->attempt; - $qid = $state->question; - $checksum = question_get_toggleflag_checksum($aid, $qid, $qsid); - $postdata = "qsid=$qsid&aid=$aid&qid=$qid&checksum=$checksum&sesskey=" . - sesskey() . '&newstate='; - $flagcontent = '' . - '' . - '' . "\n"; - question_init_qengine_js(); - break; - default: - $flagcontent = ''; - } - if ($flagcontent) { - echo '
' . $flagcontent . "
\n"; - } - } - - /** - * Work out the actual img tag needed for the flag - * - * @param boolean $flagged whether the question is currently flagged. - * @param string $id an id to be added as an attribute to the img (optional). - * @return string the img tag. - */ - protected function get_question_flag_tag($flagged, $id = '') { - global $OUTPUT; - if ($id) { - $id = 'id="' . $id . '" '; - } - if ($flagged) { - $img = 'i/flagged'; - } else { - $img = 'i/unflagged'; - } - return '' . get_string('flagthisquestion', 'question') . ''; - } - - /** - * Get a link to an edit icon for this question, if the current user is allowed - * to edit it. - * - * @param object $question the question object. - * @param object $cmoptions the options from the module. If $cmoptions->thispageurl is set - * then the link will be to edit the question in this browser window, then return to - * $cmoptions->thispageurl. Otherwise the link will be to edit in a popup. - * @return string the HTML of the link, or nothing it the currenty user is not allowed to edit. - */ - function get_question_edit_link($question, $cmoptions, $options) { - global $CFG, $OUTPUT; - - /// Is this user allowed to edit this question? - if (!empty($options->noeditlink) || !question_has_capability_on($question, 'edit')) { - return ''; - } - - /// Work out the right URL. - $url = new moodle_url('/question/question.php', array('id' => $question->id)); - if (!empty($cmoptions->cmid)) { - $url->param('cmid', $cmoptions->cmid); - } else if (!empty($cmoptions->course)) { - $url->param('courseid', $cmoptions->course); - } else { - print_error('missingcourseorcmidtolink', 'question'); - } - - $icon = new pix_icon('t/edit', get_string('edit')); - - $action = null; - if (!empty($cmoptions->thispageurl)) { - // The module allow editing in the same window, print an ordinary - // link with a returnurl. - $url->param('returnurl', $cmoptions->thispageurl); - } else { - // We have to edit in a pop-up. - $url->param('inpopup', 1); - $action = new popup_action('click', $link, 'editquestion'); - } - - return $OUTPUT->action_icon($url, $icon, $action); - } - - /** - * Print history of responses - * - * Used by print_question() - */ - function history($question, $state, $number, $cmoptions, $options) { - global $DB, $OUTPUT; - - if (empty($options->history)) { - return ''; - } - - $params = array('aid' => $state->attempt); - if (isset($question->randomquestionid)) { - $params['qid'] = $question->randomquestionid; - $randomprefix = 'random' . $question->id . '-'; - } else { - $params['qid'] = $question->id; - $randomprefix = ''; - } - if ($options->history == 'all') { - $eventtest = 'event > 0'; - } else { - $eventtest = 'event IN (' . QUESTION_EVENTS_GRADED . ')'; - } - $states = $DB->get_records_select('question_states', - 'attempt = :aid AND question = :qid AND ' . $eventtest, $params, 'seq_number ASC'); - if (count($states) <= 1) { - return ''; - } - - $strreviewquestion = get_string('reviewresponse', 'quiz'); - $table = new html_table(); - $table->width = '100%'; - $table->head = array ( - get_string('numberabbr', 'quiz'), - get_string('action', 'quiz'), - get_string('response', 'quiz'), - get_string('time'), - ); - if ($options->scores) { - $table->head[] = get_string('score', 'quiz'); - $table->head[] = get_string('grade', 'quiz'); - } - - foreach ($states as $st) { - if ($randomprefix && strpos($st->answer, $randomprefix) === 0) { - $st->answer = substr($st->answer, strlen($randomprefix)); - } - $st->responses[''] = $st->answer; - $this->restore_session_and_responses($question, $st); - - if ($state->id == $st->id) { - $link = '' . $st->seq_number . ''; - } else if (isset($options->questionreviewlink)) { - $reviewlink = new moodle_url($options->questionreviewlink); - $reviewlink->params(array('state'=>$st->id,'question'=>$question->id)); - $link = new moodle_url($reviewlink); - $action = new popup_action('click', $link, 'reviewquestion', array('height' => 450, 'width' => 650)); - $link = $OUTPUT->action_link($link, $st->seq_number, $action, array('title'=>$strreviewquestion)); - } else { - $link = $st->seq_number; - } - - if ($state->id == $st->id) { - $b = ''; - $be = ''; - } else { - $b = ''; - $be = ''; - } - - $data = array ( - $link, - $b.get_string('event'.$st->event, 'quiz').$be, - $b.$this->response_summary($question, $st).$be, - $b.userdate($st->timestamp, get_string('timestr', 'quiz')).$be, - ); - if ($options->scores) { - $data[] = $b.question_format_grade($cmoptions, $st->raw_grade).$be; - $data[] = $b.question_format_grade($cmoptions, $st->raw_grade).$be; - } - $table->data[] = $data; - } - return html_writer::table($table); - } - - /** - * Prints the score obtained and maximum score available plus any penalty - * information - * - * This function prints a summary of the scoring in the most recently - * graded state (the question may not have been submitted for marking at - * the current state). The default implementation should be suitable for most - * question types. - * @param object $question The question for which the grading details are - * to be rendered. Question type specific information - * is included. The maximum possible grade is in - * ->maxgrade. - * @param object $state The state. In particular the grading information - * is in ->grade, ->raw_grade and ->penalty. - * @param object $cmoptions - * @param object $options An object describing the rendering options. - */ - function print_question_grading_details(&$question, &$state, $cmoptions, $options) { - /* The default implementation prints the number of marks if no attempt - has been made. Otherwise it displays the grade obtained out of the - maximum grade available and a warning if a penalty was applied for the - attempt and displays the overall grade obtained counting all previous - responses (and penalties) */ - - if (QUESTION_EVENTDUPLICATE == $state->event) { - echo ' '; - print_string('duplicateresponse', 'quiz'); - } - if ($question->maxgrade > 0 && $options->scores) { - if (question_state_is_graded($state->last_graded)) { - // Display the grading details from the last graded state - $grade = new stdClass; - $grade->cur = question_format_grade($cmoptions, $state->last_graded->grade); - $grade->max = question_format_grade($cmoptions, $question->maxgrade); - $grade->raw = question_format_grade($cmoptions, $state->last_graded->raw_grade); - - // let student know wether the answer was correct - $class = question_get_feedback_class($state->last_graded->raw_grade / - $question->maxgrade); - echo '
' . get_string($class, 'quiz') . '
'; - - echo '
'; - // print grade for this submission - print_string('gradingdetails', 'quiz', $grade); - if ($cmoptions->penaltyscheme) { - // print details of grade adjustment due to penalties - if ($state->last_graded->raw_grade > $state->last_graded->grade){ - echo ' '; - print_string('gradingdetailsadjustment', 'quiz', $grade); - } - // print info about new penalty - // penalty is relevant only if the answer is not correct and further attempts are possible - if (($state->last_graded->raw_grade < $question->maxgrade / 1.01) - and (QUESTION_EVENTCLOSEANDGRADE != $state->event)) { - - if ('' !== $state->last_graded->penalty && ((float)$state->last_graded->penalty) > 0.0) { - // A penalty was applied so display it - echo ' '; - print_string('gradingdetailspenalty', 'quiz', question_format_grade($cmoptions, $state->last_graded->penalty)); - } else { - /* No penalty was applied even though the answer was - not correct (eg. a syntax error) so tell the student - that they were not penalised for the attempt */ - echo ' '; - print_string('gradingdetailszeropenalty', 'quiz'); - } - } - } - echo '
'; - } - } - } - - /** - * Prints the main content of the question including any interactions - * - * This function prints the main content of the question including the - * interactions for the question in the state given. The last graded responses - * are printed or indicated and the current responses are selected or filled in. - * Any names (eg. for any form elements) are prefixed with $question->name_prefix. - * This method is called from the print_question method. - * @param object $question The question to be rendered. Question type - * specific information is included. The name - * prefix for any named elements is in ->name_prefix. - * @param object $state The state to render the question in. The grading - * information is in ->grade, ->raw_grade and - * ->penalty. The current responses are in - * ->responses. This is an associative array (or the - * empty string or null in the case of no responses - * submitted). The last graded state is in - * ->last_graded (hence the most recently graded - * responses are in ->last_graded->responses). The - * question type specific information is also - * included. - * The state is passed by reference because some adaptive - * questions may want to update it during rendering - * @param object $cmoptions - * @param object $options An object describing the rendering options. - */ - function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) { - /* This default implementation prints an error and must be overridden - by all question type implementations, unless the default implementation - of print_question has been overridden. */ - global $OUTPUT; - echo $OUTPUT->notification('Error: Question formulation and input controls has not' - .' been implemented for question type '.$this->name()); - } - - function check_file_access($question, $state, $options, $contextid, $component, - $filearea, $args) { - - if ($component == 'question' && $filearea == 'questiontext') { - // Question text always visible. - return true; - - } else if ($component == 'question' && $filearea = 'generalfeedback') { - return $options->generalfeedback && question_state_is_graded($state->last_graded); - - } else { - // Unrecognised component or filearea. - return false; - } - } - - /** - * Prints the submit button(s) for the question in the given state - * - * This function prints the submit button(s) for the question in the - * given state. The name of any button created will be prefixed with the - * unique prefix for the question in $question->name_prefix. The suffix - * 'submit' is reserved for the single question submit button and the suffix - * 'validate' is reserved for the single question validate button (for - * question types which support it). Other suffixes will result in a response - * of that name in $state->responses which the printing and grading methods - * can then use. - * @param object $question The question for which the submit button(s) are to - * be rendered. Question type specific information is - * included. The name prefix for any - * named elements is in ->name_prefix. - * @param object $state The state to render the buttons for. The - * question type specific information is also - * included. - * @param object $cmoptions - * @param object $options An object describing the rendering options. - */ - function print_question_submit_buttons(&$question, &$state, $cmoptions, $options) { - // The default implementation should be suitable for most question types. - // It prints a mark button in the case where individual marking is allowed. - if (($cmoptions->optionflags & QUESTION_ADAPTIVE) and !$options->readonly) { - echo ''; - } - } - - /** - * Return a summary of the student response - * - * This function returns a short string of no more than a given length that - * summarizes the student's response in the given $state. This is used for - * example in the response history table. This string should already be - * formatted for output. - * @return string The summary of the student response - * @param object $question - * @param object $state The state whose responses are to be summarized - * @param int $length The maximum length of the returned string - */ - function response_summary($question, $state, $length = 80, $formatting = true) { - // This should almost certainly be overridden - $responses = $this->get_actual_response($question, $state); - if ($formatting){ - $responses = $this->format_responses($responses, $question->questiontextformat); - } - $responses = implode('; ', $responses); - return shorten_text($responses, $length); - } - /** - * @param array responses is an array of responses. - * @return formatted responses - */ - function format_responses($responses, $format){ - $toreturn = array(); - foreach ($responses as $response){ - $toreturn[] = $this->format_response($response, $format); - } - return $toreturn; - } - /** - * @param string response is a response. - * @return formatted response - */ - function format_response($response, $format){ - return s($response); - } - /** - * Renders the question for printing and returns the LaTeX source produced - * - * This function should render the question suitable for a printed problem - * or solution sheet in LaTeX and return the rendered output. - * @return string The LaTeX output. - * @param object $question The question to be rendered. Question type - * specific information is included. - * @param object $state The state to render the question in. The - * question type specific information is also - * included. - * @param object $cmoptions - * @param string $type Indicates if the question or the solution is to be - * rendered with the values 'question' and - * 'solution'. - */ - function get_texsource(&$question, &$state, $cmoptions, $type) { - // The default implementation simply returns a string stating that - // the question is only available online. - - return get_string('onlineonly', 'texsheet'); - } - - /** - * Compares two question states for equivalence of the student's responses - * - * The responses for the two states must be examined to see if they represent - * equivalent answers to the question by the student. This method will be - * invoked for each of the previous states of the question before grading - * occurs. If the student is found to have already attempted the question - * with equivalent responses then the attempt at the question is ignored; - * grading does not occur and the state does not change. Thus they are not - * penalized for this case. - * @return boolean - * @param object $question The question for which the states are to be - * compared. Question type specific information is - * included. - * @param object $state The state of the question. The responses are in - * ->responses. This is the only field of $state - * that it is safe to use. - * @param object $teststate The state whose responses are to be - * compared. The state will be of the same age or - * older than $state. If possible, the method should - * only use the field $teststate->responses, however - * any field that is set up by restore_session_and_responses - * can be used. - */ - function compare_responses(&$question, $state, $teststate) { - // The default implementation performs a comparison of the response - // arrays. The ordering of the arrays does not matter. - // Question types may wish to override this (eg. to ignore trailing - // white space or to make "7.0" and "7" compare equal). - - // In php neither == nor === compare arrays the way you want. The following - // ensures that the arrays have the same keys, with the same values. - $result = false; - $diff1 = array_diff_assoc($state->responses, $teststate->responses); - if (empty($diff1)) { - $diff2 = array_diff_assoc($teststate->responses, $state->responses); - $result = empty($diff2); - } - - return $result; - } - - /** - * Checks whether a response matches a given answer - * - * This method only applies to questions that use teacher-defined answers - * - * @return boolean - */ - function test_response(&$question, &$state, $answer) { - $response = isset($state->responses['']) ? $state->responses[''] : ''; - return ($response == $answer->answer); - } - - /** - * Performs response processing and grading - * - * This function performs response processing and grading and updates - * the state accordingly. - * @return boolean Indicates success or failure. - * @param object $question The question to be graded. Question type - * specific information is included. - * @param object $state The state of the question to grade. The current - * responses are in ->responses. The last graded state - * is in ->last_graded (hence the most recently graded - * responses are in ->last_graded->responses). The - * question type specific information is also - * included. The ->raw_grade and ->penalty fields - * must be updated. The method is able to - * close the question session (preventing any further - * attempts at this question) by setting - * $state->event to QUESTION_EVENTCLOSEANDGRADE - * @param object $cmoptions - */ - function grade_responses(&$question, &$state, $cmoptions) { - // The default implementation uses the test_response method to - // compare what the student entered against each of the possible - // answers stored in the question, and uses the grade from the - // first one that matches. It also sets the marks and penalty. - // This should be good enought for most simple question types. - - $state->raw_grade = 0; - foreach($question->options->answers as $answer) { - if($this->test_response($question, $state, $answer)) { - $state->raw_grade = $answer->fraction; - break; - } - } - - // Make sure we don't assign negative or too high marks. - $state->raw_grade = min(max((float) $state->raw_grade, - 0.0), 1.0) * $question->maxgrade; - - // Update the penalty. - $state->penalty = $question->penalty * $question->maxgrade; - - // mark the state as graded - $state->event = ($state->event == QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE; - - return true; + return $contributions; } /** @@ -1496,40 +746,221 @@ class default_questiontype { * * @return boolean Whether the wizard's last page was submitted or not. */ - function finished_edit_wizard(&$form) { + public function finished_edit_wizard(&$form) { //In the default case there is only one edit page. return true; } - /** - * Call format_text from weblib.php with the options appropriate to question types. + /* + * Find all course / site files linked from a question. * - * @param string $text the text to format. - * @param integer $text the type of text. Normally $question->questiontextformat. - * @param object $cmoptions the context the string is being displayed in. Only $cmoptions->course is used. - * @return string the formatted text. + * Need to check for links to files in question_answers.answer and feedback + * and in question table in generalfeedback and questiontext fields. Methods + * on child classes will also check extra question specific fields. + * + * Needs to be overriden for child classes that have extra fields containing + * html. + * + * @param string html the html to search + * @param int courseid search for files for courseid course or set to siteid for + * finding site files. + * @return array of url, relative url is key and array with one item = question id as value + * relative url is relative to course/site files directory root. */ - function format_text($text, $textformat, $cmoptions = NULL) { - $formatoptions = new stdClass; - $formatoptions->noclean = true; - $formatoptions->para = false; - return format_text($text, $textformat, $formatoptions, $cmoptions === NULL ? NULL : $cmoptions->course); + public function find_file_links($question, $courseid){ + $urls = array(); + + /// Question image + if ($question->image != ''){ + if (substr(strtolower($question->image), 0, 7) == 'http://') { + $matches = array(); + + //support for older questions where we have a complete url in image field + if (preg_match('!^'.question_file_links_base_url($courseid).'(.*)!i', $question->image, $matches)){ + if ($cleanedurl = question_url_check($urls[$matches[2]])){ + $urls[$cleanedurl] = null; + } + } + } else { + if ($question->image != ''){ + if ($cleanedurl = question_url_check($question->image)){ + $urls[$cleanedurl] = null;//will be set later + } + } + + } + + } + + /// Questiontext and general feedback. + $urls += question_find_file_links_from_html($question->questiontext, $courseid); + $urls += question_find_file_links_from_html($question->generalfeedback, $courseid); + + /// Answers, if this question uses them. + if (isset($question->options->answers)){ + foreach ($question->options->answers as $answerkey => $answer){ + /// URLs in the answers themselves, if appropriate. + if ($this->has_html_answers()) { + $urls += question_find_file_links_from_html($answer->answer, $courseid); + } + /// URLs in the answer feedback. + $urls += question_find_file_links_from_html($answer->feedback, $courseid); + } + } + + /// Set all the values of the array to the question object + if ($urls){ + $urls = array_combine(array_keys($urls), array_fill(0, count($urls), array($question->id))); + } + return $urls; } - /** - * @return the best link to pass to print_error. - * @param $cmoptions as passed in from outside. + /* + * Find all course / site files linked from a question. + * + * Need to check for links to files in question_answers.answer and feedback + * and in question table in generalfeedback and questiontext fields. Methods + * on child classes will also check extra question specific fields. + * + * Needs to be overriden for child classes that have extra fields containing + * html. + * + * @param string html the html to search + * @param int course search for files for courseid course or set to siteid for + * finding site files. + * @return array of files, file name is key and array with one item = question id as value */ - function error_link($cmoptions) { + public function replace_file_links($question, $fromcourseid, $tocourseid, $url, $destination){ global $CFG; - $cm = get_coursemodule_from_instance('quiz', $cmoptions->id); - if (!empty($cm->id)) { - return $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; - } else if (!empty($cm->course)) { - return $CFG->wwwroot . '/course/view.php?id=' . $cm->course; - } else { - return ''; + $updateqrec = false; + + /// Question image + if (!empty($question->image)){ + //support for older questions where we have a complete url in image field + if (substr(strtolower($question->image), 0, 7) == 'http://') { + $questionimage = preg_replace('!^'.question_file_links_base_url($fromcourseid).preg_quote($url, '!').'$!i', $destination, $question->image, 1); + } else { + $questionimage = preg_replace('!^'.preg_quote($url, '!').'$!i', $destination, $question->image, 1); + } + if ($questionimage != $question->image){ + $question->image = $questionimage; + $updateqrec = true; + } } + + /// Questiontext and general feedback. + $question->questiontext = question_replace_file_links_in_html($question->questiontext, $fromcourseid, $tocourseid, $url, $destination, $updateqrec); + $question->generalfeedback = question_replace_file_links_in_html($question->generalfeedback, $fromcourseid, $tocourseid, $url, $destination, $updateqrec); + + /// If anything has changed, update it in the database. + if ($updateqrec){ + if (!update_record('question', addslashes_recursive($question))){ + error ('Couldn\'t update question '.$question->name); + } + } + + + /// Answers, if this question uses them. + if (isset($question->options->answers)){ + //answers that do not need updating have been unset + foreach ($question->options->answers as $answer){ + $answerchanged = false; + /// URLs in the answers themselves, if appropriate. + if ($this->has_html_answers()) { + $answer->answer = question_replace_file_links_in_html($answer->answer, $fromcourseid, $tocourseid, $url, $destination, $answerchanged); + } + /// URLs in the answer feedback. + $answer->feedback = question_replace_file_links_in_html($answer->feedback, $fromcourseid, $tocourseid, $url, $destination, $answerchanged); + /// If anything has changed, update it in the database. + if ($answerchanged){ + if (!update_record('question_answers', addslashes_recursive($answer))){ + error ('Couldn\'t update question ('.$question->name.') answer '.$answer->id); + } + } + } + } + } + +/// BACKUP FUNCTIONS //////////////////////////// + + /* + * Backup the data in the question + * + * This is used in question/backuplib.php + */ + public function backup($bf,$preferences,$question,$level=6) { + + $status = true; + $extraquestionfields = $this->extra_question_fields(); + + if (is_array($extraquestionfields)) { + $questionextensiontable = array_shift($extraquestionfields); + $record = get_record($questionextensiontable, $this->questionid_column_name(), $question); + if ($record) { + $tagname = strtoupper($this->name()); + $status = $status && fwrite($bf, start_tag($tagname, $level, true)); + foreach ($extraquestionfields as $field) { + if (!isset($record->$field)) { + echo "No data for field $field when backuping " . + $this->name() . ' question id ' . $question; + return false; + } + fwrite($bf, full_tag(strtoupper($field), $level + 1, false, $record->$field)); + } + $status = $status && fwrite($bf, end_tag($tagname, $level, true)); + } + } + + $extraasnwersfields = $this->extra_answer_fields(); + if (is_array($extraasnwersfields)) { + //TODO backup the answers, with any extra data. + } else { + $status = $status && question_backup_answers($bf, $preferences, $question); + } + return $status; + } + +/// RESTORE FUNCTIONS ///////////////// + + /* + * Restores the data in the question + * + * This is used in question/restorelib.php + */ + public function restore($old_question_id,$new_question_id,$info,$restore) { + + $status = true; + $extraquestionfields = $this->extra_question_fields(); + + if (is_array($extraquestionfields)) { + $questionextensiontable = array_shift($extraquestionfields); + $tagname = strtoupper($this->name()); + $recordinfo = $info['#'][$tagname][0]; + + $record = new stdClass; + $qidcolname = $this->questionid_column_name(); + $record->$qidcolname = $new_question_id; + foreach ($extraquestionfields as $field) { + $record->$field = backup_todb($recordinfo['#'][strtoupper($field)]['0']['#']); + } + if (!insert_record($questionextensiontable, $record)) { + echo "Can't insert record in $questionextensiontable when restoring " . + $this->name() . ' question id ' . $question; + $status = false; + } + } + //TODO restore extra data in answers + return $status; + } + + public function restore_map($old_question_id,$new_question_id,$info,$restore) { + // There is nothing to decode + return true; + } + + public function restore_recode_answer($state, $restore) { + // There is nothing to decode + return $state->answer; } /// IMPORT/EXPORT FUNCTIONS ///////////////// @@ -1540,7 +971,7 @@ class default_questiontype { * Imports question using information from extra_question_fields function * If some of you fields contains id's you'll need to reimplement this */ - function import_from_xml($data, $question, $format, $extra=null) { + public function import_from_xml($data, $question, $format, $extra=null) { $question_type = $data['@']['type']; if ($question_type != $this->name()) { return false; @@ -1557,7 +988,7 @@ class default_questiontype { $qo->qtype = $question_type; foreach ($extraquestionfields as $field) { - $qo->$field = $format->getpath($data, array('#',$field,0,'#'), $qo->$field); + $qo->$field = addslashes($format->getpath($data, array('#',$field,0,'#'), $qo->$field)); } // run through the answers @@ -1584,7 +1015,7 @@ class default_questiontype { * Export question using information from extra_question_fields function * If some of you fields contains id's you'll need to reimplement this */ - function export_to_xml($question, $format, $extra=null) { + public function export_to_xml($question, $format, $extra=null) { $extraquestionfields = $this->extra_question_fields(); if (!is_array($extraquestionfields)) { return false; @@ -1623,13 +1054,13 @@ class default_questiontype { * required to set up and save a question of any type for testing purposes. * Alternate DB table prefix may be used to facilitate data deletion. */ - function generate_test($name, $courseid=null) { + public function generate_test($name, $courseid=null) { $form = new stdClass(); $form->name = $name; $form->questiontextformat = 1; $form->questiontext = 'test question, generated by script'; - $form->defaultgrade = 1; - $form->penalty = 0.1; + $form->defaultmark = 1; + $form->penalty = 0.3333333; $form->generalfeedback = "Well done"; $context = get_context_instance(CONTEXT_COURSE, $courseid); @@ -1641,140 +1072,37 @@ class default_questiontype { $question->qtype = $this->qtype; return array($form, $question); } +} + +/** + * This class is used in the return value from + * {@link question_type::get_possible_responses()}. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_possible_response { /** - * Get question context by category id - * @param int $category - * @return object $context + * @var string the classification of this response the student gave to this + * part of the question. Must match one of the responseclasses returned by + * {@link question_type::get_possible_responses()}. */ - function get_context_by_category_id($category) { - global $DB; - $contextid = $DB->get_field('question_categories', 'contextid', array('id'=>$category)); - $context = get_context_instance_by_id($contextid); - return $context; - } - + public $responseclass; + /** @var string the actual response the student gave to this part. */ + public $fraction; /** - * Save the file belonging to one text field. - * - * @param array $field the data from the form (or from import). This will - * normally have come from the formslib editor element, so it will be an - * array with keys 'text', 'format' and 'itemid'. However, when we are - * importing, it will be an array with keys 'text', 'format' and 'files' - * @param object $context the context the question is in. - * @param string $component indentifies the file area question. - * @param string $filearea indentifies the file area questiontext, generalfeedback,answerfeedback. - * @param integer $itemid identifies the file area. - * - * @return string the text for this field, after files have been processed. + * Constructor, just an easy way to set the fields. + * @param string $responseclassid see the field descriptions above. + * @param string $response see the field descriptions above. + * @param number $fraction see the field descriptions above. */ - protected function import_or_save_files($field, $context, $component, $filearea, $itemid) { - if (!empty($field['itemid'])) { - // This is the normal case. We are safing the questions editing form. - return file_save_draft_area_files($field['itemid'], $context->id, $component, - $filearea, $itemid, $this->fileoptions, trim($field['text'])); - - } else if (!empty($field['files'])) { - // This is the case when we are doing an import. - foreach ($field['files'] as $file) { - $this->import_file($context, $component, $filearea, $itemid, $file); - } - } - return trim($field['text']); + public function __construct($responseclass, $fraction) { + $this->responseclass = $responseclass; + $this->fraction = $fraction; } - /** - * Move all the files belonging to this question from one context to another. - * @param integer $questionid the question being moved. - * @param integer $oldcontextid the context it is moving from. - * @param integer $newcontextid the context it is moving to. - */ - public function move_files($questionid, $oldcontextid, $newcontextid) { - $fs = get_file_storage(); - $fs->move_area_files_to_new_context($oldcontextid, - $newcontextid, 'question', 'questiontext', $questionid); - $fs->move_area_files_to_new_context($oldcontextid, - $newcontextid, 'question', 'generalfeedback', $questionid); - } - - /** - * Move all the files belonging to this question's answers when the question - * is moved from one context to another. - * @param integer $questionid the question being moved. - * @param integer $oldcontextid the context it is moving from. - * @param integer $newcontextid the context it is moving to. - * @param boolean $answerstoo whether there is an 'answer' question area, - * as well as an 'answerfeedback' one. Default false. - */ - protected function move_files_in_answers($questionid, $oldcontextid, $newcontextid, $answerstoo = false) { - global $DB; - $fs = get_file_storage(); - - $answerids = $DB->get_records_menu('question_answers', - array('question' => $questionid), 'id', 'id,1'); - foreach ($answerids as $answerid => $notused) { - if ($answerstoo) { - $fs->move_area_files_to_new_context($oldcontextid, - $newcontextid, 'question', 'answer', $answerid); - } - $fs->move_area_files_to_new_context($oldcontextid, - $newcontextid, 'question', 'answerfeedback', $answerid); - } - } - - /** - * Delete all the files belonging to this question. - * @param integer $questionid the question being deleted. - * @param integer $contextid the context the question is in. - */ - protected function delete_files($questionid, $contextid) { - $fs = get_file_storage(); - $fs->delete_area_files($contextid, 'question', 'questiontext', $questionid); - $fs->delete_area_files($contextid, 'question', 'generalfeedback', $questionid); - } - - /** - * Delete all the files belonging to this question's answers. - * @param integer $questionid the question being deleted. - * @param integer $contextid the context the question is in. - * @param boolean $answerstoo whether there is an 'answer' question area, - * as well as an 'answerfeedback' one. Default false. - */ - protected function delete_files_in_answers($questionid, $contextid, $answerstoo = false) { - global $DB; - $fs = get_file_storage(); - - $answerids = $DB->get_records_menu('question_answers', - array('question' => $questionid), 'id', 'id,1'); - foreach ($answerids as $answerid => $notused) { - if ($answerstoo) { - $fs->delete_area_files($contextid, 'question', 'answer', $answerid); - } - $fs->delete_area_files($contextid, 'question', 'answerfeedback', $answerid); - } - } - - function import_file($context, $component, $filearea, $itemid, $file) { - $fs = get_file_storage(); - $record = new stdclass; - if (is_object($context)) { - $record->contextid = $context->id; - } else { - $record->contextid = $context; - } - $record->component = $component; - $record->filearea = $filearea; - $record->itemid = $itemid; - $record->filename = $file->name; - $record->filepath = '/'; - return $fs->create_file_from_string($record, $this->decode_file($file)); - } - - function decode_file($file) { - switch ($file->encoding) { - case 'base64': - default: - return base64_decode($file->content); - } + public static function no_response() { + return new question_possible_response(get_string('noresponse', 'question'), 0); } } diff --git a/question/type/rendererbase.php b/question/type/rendererbase.php new file mode 100644 index 00000000000..3fad513b482 --- /dev/null +++ b/question/type/rendererbase.php @@ -0,0 +1,265 @@ +. + + +/** + * Defines the renderer base classes for question types. + * + * @package moodlecore + * @subpackage questiontypes + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Renderer base classes for question types. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class qtype_renderer extends renderer_base { + /** + * Generate the display of the formulation part of the question. This is the + * area that contains the quetsion text, and the controls for students to + * input their answers. Some question types also embed bits of feedback, for + * example ticks and crosses, in this area. + * + * @param question_attempt $qa the question attempt to display. + * @param question_display_options $options controls what should and should not be displayed. + * @return string HTML fragment. + */ + public function formulation_and_controls(question_attempt $qa, + question_display_options $options) { + return $qa->get_question()->questiontext; + } + + /** + * Output hidden form fields to clear any wrong parts of the student's response. + * + * This method will only be called if the question is in read-only mode. + * @param question_attempt $qa the question attempt to display. + * @return string HTML fragment. + */ + public function clear_wrong(question_attempt $qa) { + $response = $qa->get_last_qt_data(); + if (!$response) { + return ''; + } + $cleanresponse = $qa->get_question()->clear_wrong_from_response($response); + $output = ''; + foreach ($cleanresponse as $name => $value) { + $attr = array( + 'type' => 'hidden', + 'name' => $qa->get_qt_field_name($name), + 'value' => s($value), + ); + $output .= html_writer::empty_tag('input', $attr); + } + return $output; + } + + /** + * Generate the display of the outcome part of the question. This is the + * area that contains the various forms of feedback. This function generates + * the content of this area belonging to the question type. + * + * Subclasses will normally want to override the more specific methods + * {specific_feedback()}, {general_feedback()} and {correct_response()} + * that this method calls. + * + * @param question_attempt $qa the question attempt to display. + * @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) { + $output = ''; + $hint = null; + + if ($options->feedback) { + $output .= html_writer::nonempty_tag('div', $this->specific_feedback($qa), + array('class' => 'specificfeedback')); + $hint = $qa->get_applicable_hint(); + } + + if ($options->numpartscorrect) { + $output .= html_writer::nonempty_tag('div', $this->num_parts_correct($qa), + array('class' => 'numpartscorrect')); + } + + if ($hint) { + $output .= $this->hint($qa->get_question(), $hint); + } + + if ($options->generalfeedback) { + $output .= html_writer::nonempty_tag('div', $this->general_feedback($qa), + array('class' => 'generalfeedback')); + } + + if ($options->rightanswer) { + $output .= html_writer::nonempty_tag('div', $this->correct_response($qa), + array('class' => 'rightanswer')); + } + + return $output; + } + + /** + * Gereate the specific feedback. This is feedback that varies accordin to + * the reponse the student gave. + * @param question_attempt $qa the question attempt to display. + * @return string HTML fragment. + */ + protected function specific_feedback(question_attempt $qa) { + return ''; + } + + /** + * Gereate a brief statement of how many sub-parts of this question the + * student got right. + * @param question_attempt $qa the question attempt to display. + * @return string HTML fragment. + */ + protected function num_parts_correct(question_attempt $qa) { + $a = new stdClass; + list($a->num, $a->outof) = $qa->get_question()->get_num_parts_right( + $qa->get_last_qt_data()); + if (is_null($a->outof)) { + return ''; + } else { + return get_string('yougotnright', 'question', $a); + } + } + + /** + * Gereate the specific feedback. This is feedback that varies accordin to + * the reponse the student gave. + * @param question_attempt $qa the question attempt to display. + * @return string HTML fragment. + */ + protected function hint(question_definition $question, question_hint $hint) { + return html_writer::nonempty_tag('div', $question->format_text($hint->hint), + array('class' => 'hint')); + } + + /** + * Gereate the general feedback. This is feedback is shown ot all students. + * + * @param question_attempt $qa the question attempt to display. + * @return string HTML fragment. + */ + protected function general_feedback(question_attempt $qa) { + return $qa->get_question()->format_generalfeedback(); + } + + /** + * Gereate an automatic description of the correct response to this question. + * Not all question types can do this. If it is not possible, this method + * should just return an empty string. + * + * @param question_attempt $qa the question attempt to display. + * @return string HTML fragment. + */ + protected function correct_response(question_attempt $qa) { + return ''; + } + + /** + * Return any HTML that needs to be included in the page's when this + * question is 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 implode("\n", $qa->get_question()->qtype->find_standard_scripts_and_css()); + } + + protected function feedback_class($fraction) { + return question_state::graded_state_for_fraction($fraction) + ->get_feedback_class(); + } + + /** + * Return an appropriate icon (green tick, red cross, etc.) for a grade. + * @param float $fraction grade on a scale 0..1. + * @param boolean $selected whether to show a big or small icon. (Deprecated) + * @return string html fragment. + */ + function feedback_image($fraction, $selected = true) { + global $CFG; + + $state = question_state::graded_state_for_fraction($fraction); + + if ($state == question_state::$gradedright) { + $icon = 'tick_green'; + } else if ($state == question_state::$gradedpartial) { + $icon = 'tick_amber'; + } else { + $icon = 'cross_red'; + } + if ($selected) { + $icon .= '_big'; + } else { + $icon .= '_small'; + } + + $attributes = array( + 'src' => $CFG->pixpath . '/i/' . $icon . '.gif', + 'alt' => get_string($state->get_feedback_class(), 'question'), + 'class' => 'questioncorrectnessicon', + ); + + return html_writer::empty_tag('img', $attributes); + } +} + +/** + * Renderer base classes for question types. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class qtype_with_combined_feedback_renderer extends qtype_renderer { + protected function combined_feedback(question_attempt $qa) { + $question = $qa->get_question(); + + $state = $qa->get_state(); + + if (!$state->is_finished()) { + $response = $qa->get_last_qt_data(); + if (!$qa->get_question()->is_gradable_response($response)) { + return ''; + } + list($notused, $state) = $qa->get_question()->grade_response($response); + } + + $feedback = ''; + if ($state->is_correct()) { + $feedback = $question->correctfeedback; + } else if ($state->is_partially_correct()) { + $feedback = $question->partiallycorrectfeedback; + } else if ($state->is_incorrect()) { + $feedback = $question->incorrectfeedback; + } + + if ($feedback) { + $feedback = $question->format_text($feedback); + } + + return $feedback; + } +} diff --git a/question/type/truefalse/old_questiontype.php b/question/type/truefalse/old_questiontype.php new file mode 100644 index 00000000000..1dc2cc10213 --- /dev/null +++ b/question/type/truefalse/old_questiontype.php @@ -0,0 +1,317 @@ +. + +defined('MOODLE_INTERNAL') || die(); + +///////////////// +/// TRUEFALSE /// +///////////////// + +/// QUESTION TYPE CLASS ////////////////// +/** + * @package questionbank + * @subpackage questiontypes + */ +class question_truefalse_qtype extends default_questiontype { + + function name() { + return 'truefalse'; + } + + function save_question_options($question) { + global $DB; + $result = new stdClass; + $context = $question->context; + + // Fetch old answer ids so that we can reuse them + $oldanswers = $DB->get_records('question_answers', + array('question' => $question->id), 'id ASC'); + + // Save the true answer - update an existing answer if possible. + $answer = array_shift($oldanswers); + if (!$answer) { + $answer = new stdClass(); + $answer->question = $question->id; + $answer->answer = ''; + $answer->feedback = ''; + $answer->id = $DB->insert_record('question_answers', $answer); + } + + $answer->answer = get_string('true', 'quiz'); + $answer->fraction = $question->correctanswer; + $answer->feedback = $this->import_or_save_files($question->feedbacktrue, + $context, 'question', 'answerfeedback', $answer->id); + $answer->feedbackformat = $question->feedbacktrue['format']; + $DB->update_record('question_answers', $answer); + $trueid = $answer->id; + + // Save the false answer - update an existing answer if possible. + $answer = array_shift($oldanswers); + if (!$answer) { + $answer = new stdClass(); + $answer->question = $question->id; + $answer->answer = ''; + $answer->feedback = ''; + $answer->id = $DB->insert_record('question_answers', $answer); + } + + $answer->answer = get_string('false', 'quiz'); + $answer->fraction = 1 - (int)$question->correctanswer; + $answer->feedback = $this->import_or_save_files($question->feedbackfalse, + $context, 'question', 'answerfeedback', $answer->id); + $answer->feedbackformat = $question->feedbackfalse['format']; + $DB->update_record('question_answers', $answer); + $falseid = $answer->id; + + // Delete any left over old answer records. + $fs = get_file_storage(); + foreach($oldanswers as $oldanswer) { + $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id); + $DB->delete_records('question_answers', array('id' => $oldanswer->id)); + } + + // Save question options in question_truefalse table + if ($options = $DB->get_record('question_truefalse', array('question' => $question->id))) { + // No need to do anything, since the answer IDs won't have changed + // But we'll do it anyway, just for robustness + $options->trueanswer = $trueid; + $options->falseanswer = $falseid; + $DB->update_record('question_truefalse', $options); + } else { + $options = new stdClass(); + $options->question = $question->id; + $options->trueanswer = $trueid; + $options->falseanswer = $falseid; + $DB->insert_record('question_truefalse', $options); + } + + return true; + } + + /** + * Loads the question type specific options for the question. + */ + function get_question_options(&$question) { + global $DB, $OUTPUT; + // Get additional information from database + // and attach it to the question object + if (!$question->options = $DB->get_record('question_truefalse', array('question' => $question->id))) { + echo $OUTPUT->notification('Error: Missing question options!'); + return false; + } + // Load the answers + if (!$question->options->answers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) { + echo $OUTPUT->notification('Error: Missing question answers for truefalse question ' . $question->id . '!'); + return false; + } + + return true; + } + + function delete_question($questionid, $contextid) { + global $DB; + $DB->delete_records('question_truefalse', array('question' => $questionid)); + + parent::delete_question($questionid, $contextid); + } + + function compare_responses($question, $state, $teststate) { + if (isset($state->responses['']) && isset($teststate->responses[''])) { + return $state->responses[''] === $teststate->responses['']; + } else if (isset($teststate->responses['']) && $teststate->responses[''] === '' && + !isset($state->responses[''])) { + // Nothing selected in the past, and nothing selected now. + return true; + } + return false; + } + + function get_correct_responses(&$question, &$state) { + // The correct answer is the one which gives full marks + foreach ($question->options->answers as $answer) { + if (((int) $answer->fraction) === 1) { + return array('' => $answer->id); + } + } + return null; + } + + /** + * Prints the main content of the question including any interactions + */ + function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) { + global $CFG; + $context = $this->get_context_by_category_id($question->category); + + $readonly = $options->readonly ? ' disabled="disabled"' : ''; + + $formatoptions = new stdClass; + $formatoptions->noclean = true; + $formatoptions->para = false; + + // Print question formulation + $questiontext = format_text($question->questiontext, + $question->questiontextformat, + $formatoptions, $cmoptions->course); + + $answers = &$question->options->answers; + $trueanswer = &$answers[$question->options->trueanswer]; + $falseanswer = &$answers[$question->options->falseanswer]; + $correctanswer = ($trueanswer->fraction == 1) ? $trueanswer : $falseanswer; + + $trueclass = ''; + $falseclass = ''; + $truefeedbackimg = ''; + $falsefeedbackimg = ''; + + // Work out which radio button to select (if any) + if (isset($state->responses[''])) { + $response = $state->responses['']; + } else { + $response = ''; + } + $truechecked = ($response == $trueanswer->id) ? ' checked="checked"' : ''; + $falsechecked = ($response == $falseanswer->id) ? ' checked="checked"' : ''; + + // Work out visual feedback for answer correctness. + if ($options->feedback) { + if ($truechecked) { + $trueclass = question_get_feedback_class($trueanswer->fraction); + } else if ($falsechecked) { + $falseclass = question_get_feedback_class($falseanswer->fraction); + } + } + if ($options->feedback || $options->correct_responses) { + if (isset($answers[$response])) { + $truefeedbackimg = question_get_feedback_image($trueanswer->fraction, !empty($truechecked) && $options->feedback); + $falsefeedbackimg = question_get_feedback_image($falseanswer->fraction, !empty($falsechecked) && $options->feedback); + } + } + + $inputname = ' name="'.$question->name_prefix.'" '; + $trueid = $question->name_prefix.'true'; + $falseid = $question->name_prefix.'false'; + + $radiotrue = ''; + $radiofalse = ''; + + $feedback = ''; + if ($options->feedback and isset($answers[$response])) { + $chosenanswer = $answers[$response]; + $chosenanswer->feedback = quiz_rewrite_question_urls($chosenanswer->feedback, 'pluginfile.php', $context->id, 'question', 'answerfeedback', array($state->attempt, $state->question), $chosenanswer->id); + $feedback = format_text($chosenanswer->feedback, $chosenanswer->feedbackformat, $formatoptions, $cmoptions->course); + } + + include("$CFG->dirroot/question/type/truefalse/display.html"); + } + + function move_files($questionid, $oldcontextid, $newcontextid) { + parent::move_files($questionid, $oldcontextid, $newcontextid); + $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); + } + + protected function delete_files($questionid, $contextid) { + parent::delete_files($questionid, $contextid); + $this->delete_files_in_answers($questionid, $contextid); + } + + function check_file_access($question, $state, $options, $contextid, $component, + $filearea, $args) { + if ($component == 'question' && $filearea == 'answerfeedback') { + + $answerid = reset($args); // itemid is answer id. + $answers = &$question->options->answers; + if (isset($state->responses[''])) { + $response = $state->responses['']; + } else { + $response = ''; + } + + return $options->feedback && isset($answers[$response]) && $answerid == $response; + + } else { + return parent::check_file_access($question, $state, $options, $contextid, $component, + $filearea, $args); + } + } + + function grade_responses(&$question, &$state, $cmoptions) { + if (isset($state->responses['']) && isset($question->options->answers[$state->responses['']])) { + $state->raw_grade = $question->options->answers[$state->responses['']]->fraction * $question->maxgrade; + } else { + $state->raw_grade = 0; + } + // Only allow one attempt at the question + $state->penalty = 1 * $question->maxgrade; + + // mark the state as graded + $state->event = ($state->event == QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE; + + return true; + } + + function get_actual_response($question, $state) { + if (isset($question->options->answers[$state->responses['']])) { + $responses[] = $question->options->answers[$state->responses['']]->answer; + } else { + $responses[] = ''; + } + return $responses; + } + /** + * @param object $question + * @return mixed either a integer score out of 1 that the average random + * guess by a student might give or an empty string which means will not + * calculate. + */ + function get_random_guess_score($question) { + return 0.5; + } + + /** + * Runs all the code required to set up and save an essay question for testing purposes. + * Alternate DB table prefix may be used to facilitate data deletion. + */ + function generate_test($name, $courseid = null) { + global $DB; + list($form, $question) = parent::generate_test($name, $courseid); + $question->category = $form->category; + + $form->questiontext = "This question is really stupid"; + $form->penalty = 1; + $form->defaultgrade = 1; + $form->correctanswer = 0; + $form->feedbacktrue = 'Can you justify such a hasty judgment?'; + $form->feedbackfalse = 'Wisdom has spoken!'; + + if ($courseid) { + $course = $DB->get_record('course', array('id' => $courseid)); + } + + return $this->save_question($question, $form); + } +} +//// END OF CLASS //// + +////////////////////////////////////////////////////////////////////////// +//// INITIATION - Without this line the question type is not in use... /// +////////////////////////////////////////////////////////////////////////// +question_register_questiontype(new question_truefalse_qtype()); diff --git a/question/type/truefalse/question.php b/question/type/truefalse/question.php new file mode 100644 index 00000000000..7e1a9b8f008 --- /dev/null +++ b/question/type/truefalse/question.php @@ -0,0 +1,97 @@ +. + + +/** + * True-false question definition class. + * + * @package qtype_truefalse + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Represents a true-false question. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_truefalse_question extends question_graded_automatically { + public $rightanswer; + public $truefeedback; + public $falsefeedback; + + public function get_expected_data() { + return array('answer' => PARAM_INTEGER); + } + + public function get_correct_response() { + return array('answer' => (int) $this->rightanswer); + } + + public function summarise_response(array $response) { + if (!array_key_exists('answer', $response)) { + return null; + } else if ($response['answer']) { + return get_string('true', 'qtype_truefalse'); + } else { + return get_string('false', 'qtype_truefalse'); + } + } + + public function classify_response(array $response) { + if (!array_key_exists('answer', $response)) { + return array($this->id => question_classified_response::no_response()); + } + list($fraction) = $this->grade_response($response); + if ($response['answer']) { + return array($this->id => new question_classified_response(1, + get_string('true', 'qtype_truefalse'), $fraction)); + } else { + return array($this->id => new question_classified_response(0, + get_string('false', 'qtype_truefalse'), $fraction)); + } + } + + public function is_complete_response(array $response) { + return array_key_exists('answer', $response); + } + + public function get_validation_error(array $response) { + if ($this->is_gradable_response($response)) { + return ''; + } + return get_string('pleaseselectananswer', 'qtype_truefalse'); + } + + public function is_same_response(array $prevresponse, array $newresponse) { + return question_utils::arrays_same_at_key_missing_is_blank( + $prevresponse, $newresponse, 'answer'); + } + + public function grade_response(array $response) { + if ($this->rightanswer == true && $response['answer'] == true) { + $fraction = 1; + } else if ($this->rightanswer == false && $response['answer'] == false) { + $fraction = 1; + } else { + $fraction = 0; + } + return array($fraction, question_state::graded_state_for_fraction($fraction)); + } +} diff --git a/question/type/truefalse/questiontype.php b/question/type/truefalse/questiontype.php index 1dc2cc10213..4b92257bbf2 100644 --- a/question/type/truefalse/questiontype.php +++ b/question/type/truefalse/questiontype.php @@ -15,303 +15,284 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -defined('MOODLE_INTERNAL') || die(); -///////////////// -/// TRUEFALSE /// -///////////////// - -/// QUESTION TYPE CLASS ////////////////// /** - * @package questionbank - * @subpackage questiontypes + * Question type class for the true-false question type. + * + * @package qtype_truefalse + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class question_truefalse_qtype extends default_questiontype { - function name() { - return 'truefalse'; - } - function save_question_options($question) { - global $DB; +/** + * The true-false question type class. + * + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_truefalse extends question_type { + public function save_question_options($question) { $result = new stdClass; - $context = $question->context; - // Fetch old answer ids so that we can reuse them - $oldanswers = $DB->get_records('question_answers', - array('question' => $question->id), 'id ASC'); - - // Save the true answer - update an existing answer if possible. - $answer = array_shift($oldanswers); - if (!$answer) { - $answer = new stdClass(); - $answer->question = $question->id; - $answer->answer = ''; - $answer->feedback = ''; - $answer->id = $DB->insert_record('question_answers', $answer); + // fetch old answer ids so that we can reuse them + if (!$oldanswers = get_records("question_answers", "question", $question->id, "id ASC")) { + $oldanswers = array(); } - $answer->answer = get_string('true', 'quiz'); - $answer->fraction = $question->correctanswer; - $answer->feedback = $this->import_or_save_files($question->feedbacktrue, - $context, 'question', 'answerfeedback', $answer->id); - $answer->feedbackformat = $question->feedbacktrue['format']; - $DB->update_record('question_answers', $answer); - $trueid = $answer->id; - - // Save the false answer - update an existing answer if possible. - $answer = array_shift($oldanswers); - if (!$answer) { - $answer = new stdClass(); - $answer->question = $question->id; - $answer->answer = ''; - $answer->feedback = ''; - $answer->id = $DB->insert_record('question_answers', $answer); + // Save answer 'True' + if ($true = array_shift($oldanswers)) { // Existing answer, so reuse it + $true->answer = get_string("true", "quiz"); + $true->fraction = $question->correctanswer; + $true->feedback = $question->feedbacktrue; + if (!update_record("question_answers", $true)) { + $result->error = "Could not update quiz answer \"true\")!"; + return $result; + } + } else { + unset($true); + $true->answer = get_string("true", "quiz"); + $true->question = $question->id; + $true->fraction = $question->correctanswer; + $true->feedback = $question->feedbacktrue; + if (!$true->id = insert_record("question_answers", $true)) { + $result->error = "Could not insert quiz answer \"true\")!"; + return $result; + } } - $answer->answer = get_string('false', 'quiz'); - $answer->fraction = 1 - (int)$question->correctanswer; - $answer->feedback = $this->import_or_save_files($question->feedbackfalse, - $context, 'question', 'answerfeedback', $answer->id); - $answer->feedbackformat = $question->feedbackfalse['format']; - $DB->update_record('question_answers', $answer); - $falseid = $answer->id; + // Save answer 'False' + if ($false = array_shift($oldanswers)) { // Existing answer, so reuse it + $false->answer = get_string("false", "quiz"); + $false->fraction = 1 - (int)$question->correctanswer; + $false->feedback = $question->feedbackfalse; + if (!update_record("question_answers", $false)) { + $result->error = "Could not insert quiz answer \"false\")!"; + return $result; + } + } else { + unset($false); + $false->answer = get_string("false", "quiz"); + $false->question = $question->id; + $false->fraction = 1 - (int)$question->correctanswer; + $false->feedback = $question->feedbackfalse; + if (!$false->id = insert_record("question_answers", $false)) { + $result->error = "Could not insert quiz answer \"false\")!"; + return $result; + } + } - // Delete any left over old answer records. - $fs = get_file_storage(); - foreach($oldanswers as $oldanswer) { - $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id); - $DB->delete_records('question_answers', array('id' => $oldanswer->id)); + // delete any leftover old answer records (there couldn't really be any, but who knows) + if (!empty($oldanswers)) { + foreach($oldanswers as $oa) { + delete_records('question_answers', 'id', $oa->id); + } } // Save question options in question_truefalse table - if ($options = $DB->get_record('question_truefalse', array('question' => $question->id))) { + if ($options = get_record("question_truefalse", "question", $question->id)) { // No need to do anything, since the answer IDs won't have changed // But we'll do it anyway, just for robustness - $options->trueanswer = $trueid; - $options->falseanswer = $falseid; - $DB->update_record('question_truefalse', $options); + $options->trueanswer = $true->id; + $options->falseanswer = $false->id; + if (!update_record("question_truefalse", $options)) { + $result->error = "Could not update quiz truefalse options! (id=$options->id)"; + return $result; + } } else { - $options = new stdClass(); + unset($options); $options->question = $question->id; - $options->trueanswer = $trueid; - $options->falseanswer = $falseid; - $DB->insert_record('question_truefalse', $options); + $options->trueanswer = $true->id; + $options->falseanswer = $false->id; + if (!insert_record("question_truefalse", $options)) { + $result->error = "Could not insert quiz truefalse options!"; + return $result; + } } + $this->save_hints($question); + return true; } /** * Loads the question type specific options for the question. */ - function get_question_options(&$question) { - global $DB, $OUTPUT; + public function get_question_options($question) { // Get additional information from database // and attach it to the question object - if (!$question->options = $DB->get_record('question_truefalse', array('question' => $question->id))) { - echo $OUTPUT->notification('Error: Missing question options!'); + if (!$question->options = get_record('question_truefalse', 'question', $question->id)) { + notify('Error: Missing question options!'); return false; } - // Load the answers - if (!$question->options->answers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) { - echo $OUTPUT->notification('Error: Missing question answers for truefalse question ' . $question->id . '!'); - return false; - } + + parent::get_question_options($question); return true; } - function delete_question($questionid, $contextid) { - global $DB; - $DB->delete_records('question_truefalse', array('question' => $questionid)); - - parent::delete_question($questionid, $contextid); - } - - function compare_responses($question, $state, $teststate) { - if (isset($state->responses['']) && isset($teststate->responses[''])) { - return $state->responses[''] === $teststate->responses['']; - } else if (isset($teststate->responses['']) && $teststate->responses[''] === '' && - !isset($state->responses[''])) { - // Nothing selected in the past, and nothing selected now. - return true; + protected function initialise_question_instance(question_definition $question, $questiondata) { + parent::initialise_question_instance($question, $questiondata); + $answers = $questiondata->options->answers; + if ($answers[$questiondata->options->trueanswer]->fraction > 0.99) { + $question->rightanswer = true; + } else { + $question->rightanswer = false; } - return false; - } - - function get_correct_responses(&$question, &$state) { - // The correct answer is the one which gives full marks - foreach ($question->options->answers as $answer) { - if (((int) $answer->fraction) === 1) { - return array('' => $answer->id); - } - } - return null; + $question->truefeedback = $answers[$questiondata->options->trueanswer]->feedback; + $question->falsefeedback = $answers[$questiondata->options->falseanswer]->feedback; } /** - * Prints the main content of the question including any interactions + * Deletes question from the question-type specific tables + * + * @return boolean Success/Failure + * @param object $question The question being deleted */ - function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) { - global $CFG; - $context = $this->get_context_by_category_id($question->category); - - $readonly = $options->readonly ? ' disabled="disabled"' : ''; - - $formatoptions = new stdClass; - $formatoptions->noclean = true; - $formatoptions->para = false; - - // Print question formulation - $questiontext = format_text($question->questiontext, - $question->questiontextformat, - $formatoptions, $cmoptions->course); - - $answers = &$question->options->answers; - $trueanswer = &$answers[$question->options->trueanswer]; - $falseanswer = &$answers[$question->options->falseanswer]; - $correctanswer = ($trueanswer->fraction == 1) ? $trueanswer : $falseanswer; - - $trueclass = ''; - $falseclass = ''; - $truefeedbackimg = ''; - $falsefeedbackimg = ''; - - // Work out which radio button to select (if any) - if (isset($state->responses[''])) { - $response = $state->responses['']; - } else { - $response = ''; - } - $truechecked = ($response == $trueanswer->id) ? ' checked="checked"' : ''; - $falsechecked = ($response == $falseanswer->id) ? ' checked="checked"' : ''; - - // Work out visual feedback for answer correctness. - if ($options->feedback) { - if ($truechecked) { - $trueclass = question_get_feedback_class($trueanswer->fraction); - } else if ($falsechecked) { - $falseclass = question_get_feedback_class($falseanswer->fraction); - } - } - if ($options->feedback || $options->correct_responses) { - if (isset($answers[$response])) { - $truefeedbackimg = question_get_feedback_image($trueanswer->fraction, !empty($truechecked) && $options->feedback); - $falsefeedbackimg = question_get_feedback_image($falseanswer->fraction, !empty($falsechecked) && $options->feedback); - } - } - - $inputname = ' name="'.$question->name_prefix.'" '; - $trueid = $question->name_prefix.'true'; - $falseid = $question->name_prefix.'false'; - - $radiotrue = ''; - $radiofalse = ''; - - $feedback = ''; - if ($options->feedback and isset($answers[$response])) { - $chosenanswer = $answers[$response]; - $chosenanswer->feedback = quiz_rewrite_question_urls($chosenanswer->feedback, 'pluginfile.php', $context->id, 'question', 'answerfeedback', array($state->attempt, $state->question), $chosenanswer->id); - $feedback = format_text($chosenanswer->feedback, $chosenanswer->feedbackformat, $formatoptions, $cmoptions->course); - } - - include("$CFG->dirroot/question/type/truefalse/display.html"); + public function delete_question($questionid) { + delete_records("question_truefalse", "question", $questionid); + return parent::delete_question($questionid); } - function move_files($questionid, $oldcontextid, $newcontextid) { - parent::move_files($questionid, $oldcontextid, $newcontextid); - $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); - } - - protected function delete_files($questionid, $contextid) { - parent::delete_files($questionid, $contextid); - $this->delete_files_in_answers($questionid, $contextid); - } - - function check_file_access($question, $state, $options, $contextid, $component, - $filearea, $args) { - if ($component == 'question' && $filearea == 'answerfeedback') { - - $answerid = reset($args); // itemid is answer id. - $answers = &$question->options->answers; - if (isset($state->responses[''])) { - $response = $state->responses['']; - } else { - $response = ''; - } - - return $options->feedback && isset($answers[$response]) && $answerid == $response; - - } else { - return parent::check_file_access($question, $state, $options, $contextid, $component, - $filearea, $args); - } - } - - function grade_responses(&$question, &$state, $cmoptions) { - if (isset($state->responses['']) && isset($question->options->answers[$state->responses['']])) { - $state->raw_grade = $question->options->answers[$state->responses['']]->fraction * $question->maxgrade; - } else { - $state->raw_grade = 0; - } - // Only allow one attempt at the question - $state->penalty = 1 * $question->maxgrade; - - // mark the state as graded - $state->event = ($state->event == QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE; - - return true; - } - - function get_actual_response($question, $state) { - if (isset($question->options->answers[$state->responses['']])) { - $responses[] = $question->options->answers[$state->responses['']]->answer; - } else { - $responses[] = ''; - } - return $responses; - } - /** - * @param object $question - * @return mixed either a integer score out of 1 that the average random - * guess by a student might give or an empty string which means will not - * calculate. - */ - function get_random_guess_score($question) { + function get_random_guess_score($questiondata) { return 0.5; } + function get_possible_responses($questiondata) { + return array( + $questiondata->id => array( + 0 => new question_possible_response(get_string('false', 'qtype_truefalse'), + $questiondata->options->answers[ + $questiondata->options->falseanswer]->fraction), + 1 => new question_possible_response(get_string('true', 'qtype_truefalse'), + $questiondata->options->answers[ + $questiondata->options->trueanswer]->fraction), + null => question_possible_response::no_response() + ) + ); + } + +/// BACKUP FUNCTIONS //////////////////////////// + + /* + * Backup the data in a truefalse question + * + * This is used in question/backuplib.php + */ + public function backup($bf,$preferences,$question,$level=6) { + + $status = true; + + $truefalses = get_records("question_truefalse","question",$question,"id"); + //If there are truefalses + if ($truefalses) { + //Iterate over each truefalse + foreach ($truefalses as $truefalse) { + $status = fwrite ($bf,start_tag("TRUEFALSE",$level,true)); + //Print truefalse contents + fwrite ($bf,full_tag("TRUEANSWER",$level+1,false,$truefalse->trueanswer)); + fwrite ($bf,full_tag("FALSEANSWER",$level+1,false,$truefalse->falseanswer)); + $status = fwrite ($bf,end_tag("TRUEFALSE",$level,true)); + } + //Now print question_answers + $status = question_backup_answers($bf,$preferences,$question); + } + return $status; + } + +/// RESTORE FUNCTIONS ///////////////// + + /* + * Restores the data in the question + * + * This is used in question/restorelib.php + */ + public function restore($old_question_id,$new_question_id,$info,$restore) { + + $status = true; + + //Get the truefalse array + if (array_key_exists('TRUEFALSE', $info['#'])) { + $truefalses = $info['#']['TRUEFALSE']; + } else { + $truefalses = array(); + } + + //Iterate over truefalse + for($i = 0; $i < sizeof($truefalses); $i++) { + $tru_info = $truefalses[$i]; + + //Now, build the question_truefalse record structure + $truefalse = new stdClass; + $truefalse->question = $new_question_id; + $truefalse->trueanswer = backup_todb($tru_info['#']['TRUEANSWER']['0']['#']); + $truefalse->falseanswer = backup_todb($tru_info['#']['FALSEANSWER']['0']['#']); + + ////We have to recode the trueanswer field + $answer = backup_getid($restore->backup_unique_code,"question_answers",$truefalse->trueanswer); + if ($answer) { + $truefalse->trueanswer = $answer->new_id; + } + + ////We have to recode the falseanswer field + $answer = backup_getid($restore->backup_unique_code,"question_answers",$truefalse->falseanswer); + if ($answer) { + $truefalse->falseanswer = $answer->new_id; + } + + //The structure is equal to the db, so insert the question_truefalse + $newid = insert_record ("question_truefalse", $truefalse); + + //Do some output + if (($i+1) % 50 == 0) { + if (!defined('RESTORE_SILENTLY')) { + echo "."; + if (($i+1) % 1000 == 0) { + echo "
"; + } + } + backup_flush(300); + } + + if (!$newid) { + $status = false; + } + } + + return $status; + } + + public function restore_recode_answer($state, $restore) { + //answer may be empty + if ($state->answer) { + $answer = backup_getid($restore->backup_unique_code,"question_answers",$state->answer); + if ($answer) { + return $answer->new_id; + } else { + echo 'Could not recode truefalse answer id '.$state->answer.' for state '.$state->oldid.'
'; + } + } + } + /** * Runs all the code required to set up and save an essay question for testing purposes. * Alternate DB table prefix may be used to facilitate data deletion. */ - function generate_test($name, $courseid = null) { - global $DB; + public function generate_test($name, $courseid = null) { list($form, $question) = parent::generate_test($name, $courseid); $question->category = $form->category; $form->questiontext = "This question is really stupid"; $form->penalty = 1; - $form->defaultgrade = 1; + $form->defaultmark = 1; $form->correctanswer = 0; - $form->feedbacktrue = 'Can you justify such a hasty judgment?'; - $form->feedbackfalse = 'Wisdom has spoken!'; + $form->feedbacktrue = array('Can you justify such a hasty judgment?'); + $form->feedbackfalse = array('Wisdom has spoken!'); if ($courseid) { - $course = $DB->get_record('course', array('id' => $courseid)); + $course = get_record('course', 'id', $courseid); } - return $this->save_question($question, $form); + return $this->save_question($question, $form, $course); } } -//// END OF CLASS //// - -////////////////////////////////////////////////////////////////////////// -//// INITIATION - Without this line the question type is not in use... /// -////////////////////////////////////////////////////////////////////////// -question_register_questiontype(new question_truefalse_qtype()); diff --git a/question/type/truefalse/renderer.php b/question/type/truefalse/renderer.php new file mode 100644 index 00000000000..bfea585d08b --- /dev/null +++ b/question/type/truefalse/renderer.php @@ -0,0 +1,142 @@ +. + + +/** + * True-false question renderer class. + * + * @package qtype_truefalse + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * Generates the output for true-false questions. + * + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_truefalse_renderer extends qtype_renderer { + public function formulation_and_controls(question_attempt $qa, + question_display_options $options) { + + $question = $qa->get_question(); + $response = $qa->get_last_qt_var('answer', ''); + + $inputname = $qa->get_qt_field_name('answer'); + $trueattributes = array( + 'type' => 'radio', + 'name' => $inputname, + 'value' => 1, + 'id' => $inputname . 'true', + ); + $falseattributes = array( + 'type' => 'radio', + 'name' => $inputname, + 'value' => 0, + 'id' => $inputname . 'false', + ); + + if ($options->readonly) { + $trueattributes['disabled'] = 'disabled'; + $falseattributes['disabled'] = 'disabled'; + } + + // Work out which radio button to select (if any) + $truechecked = false; + $falsechecked = false; + $responsearray = array(); + if ($response) { + $trueattributes['checked'] = 'checked'; + $truechecked = true; + $responsearray = array('answer' => 1); + } else if ($response !== '') { + $falseattributes['checked'] = 'checked'; + $falsechecked = true; + $responsearray = array('answer' => 1); + } + + // Work out visual feedback for answer correctness. + $trueclass = ''; + $falseclass = ''; + $truefeedbackimg = ''; + $falsefeedbackimg = ''; + if ($options->correctness) { + if ($truechecked) { + $trueclass = ' ' . $this->feedback_class((int) $question->rightanswer); + $truefeedbackimg = $this->feedback_image((int) $question->rightanswer); + } else if ($falsechecked) { + $falseclass = ' ' . $this->feedback_class((int) (!$question->rightanswer)); + $falsefeedbackimg = $this->feedback_image((int) (!$question->rightanswer)); + } + } + + $radiotrue = html_writer::empty_tag('input', $trueattributes) . + html_writer::tag('label', get_string('true', 'qtype_truefalse'), + array('for' => $trueattributes['id'])); + $radiofalse = html_writer::empty_tag('input', $falseattributes) . + html_writer::tag('label', get_string('false', 'qtype_truefalse'), + array('for' => $falseattributes['id'])); + + $result = ''; + $result .= html_writer::tag('div', $question->format_questiontext(), + array('class' => 'qtext')); + + $result .= html_writer::start_tag('div', array('class' => 'ablock')); + $result .= html_writer::tag('div', get_string('selectone', 'qtype_truefalse'), + array('class' => 'prompt')); + + $result .= html_writer::start_tag('div', array('class' => 'answer')); + $result .= html_writer::tag('span', $radiotrue . ' ' . $truefeedbackimg, + array('class' => 'r0' . $trueclass)); + $result .= html_writer::tag('span', $radiofalse . ' ' . $falsefeedbackimg, + array('class' => 'r1' . $falseclass)); + $result .= html_writer::end_tag('div'); // answer + + $result .= html_writer::end_tag('div'); // ablock + + if ($qa->get_state() == question_state::$invalid) { + $result .= html_writer::nonempty_tag('div', + $question->get_validation_error($responsearray), + array('class' => 'validationerror')); + } + + return $result; + } + + public function specific_feedback(question_attempt $qa) { + $question = $qa->get_question(); + $response = $qa->get_last_qt_var('answer', ''); + + if ($response) { + return $question->format_text($question->truefeedback); + } else { + return $question->format_text($question->falsefeedback); + } + } + + public function correct_response(question_attempt $qa) { + $question = $qa->get_question(); + + if ($question->rightanswer) { + return get_string('correctanswertrue', 'qtype_truefalse'); + } else { + return get_string('correctanswerfalse', 'qtype_truefalse'); + } + } +}