diff --git a/question/engine/upgrade/behaviourconverters.php b/question/engine/upgrade/behaviourconverters.php new file mode 100644 index 00000000000..275e49e0a81 --- /dev/null +++ b/question/engine/upgrade/behaviourconverters.php @@ -0,0 +1,509 @@ +. + +/** + * This file contains classes for handling the different question behaviours + * during upgrade. + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + + +abstract class question_behaviour_attempt_updater { + 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'] = $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 object $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_deferredfeedback_converter extends question_behaviour_attempt_updater { + 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); + } +} + + +class qbehaviour_manualgraded_converter extends question_behaviour_attempt_updater { + 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_informationitem_converter extends question_behaviour_attempt_updater { + 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_interactive_converter extends question_behaviour_attempt_updater { + 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); + } +} + diff --git a/question/engine/upgrade/logger.php b/question/engine/upgrade/logger.php new file mode 100644 index 00000000000..71e9f27ac89 --- /dev/null +++ b/question/engine/upgrade/logger.php @@ -0,0 +1,77 @@ +. + +/** + * Code that deals with logging stuff during the question engine upgrade. + * + * @package moodlecore + * @subpackage questionengine + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +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, '
' . $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); + } +} diff --git a/question/engine/upgrade/upgradelib.php b/question/engine/upgrade/upgradelib.php new file mode 100644 index 00000000000..a9f56d73b03 --- /dev/null +++ b/question/engine/upgrade/upgradelib.php @@ -0,0 +1,561 @@ +. + +/** + * 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 + */ + + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/engine/upgrade/logger.php'); +require_once($CFG->dirroot . '/question/engine/upgrade/behaviourconverters.php'); + + +/** + * 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 = $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; + } + + 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; + + 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); + } + + $qtype = question_bank::get_qtype($question->qtype, false); + if ($qtype->name() === 'missingtype') { + $this->logger->log_assumption("Dealing with question id {$question->id} + that is of an unknown type {$question->qtype}."); + $question->questiontext = '' . get_string('warningmissingtype', 'quiz') . '
' . $question->questiontext; + } + + $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 question_qtype_attempt_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 question_deleted_question_attempt_updater extends question_qtype_attempt_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/engine/upgradefromoldqe/upgrade.php b/question/engine/upgradefromoldqe/upgrade.php deleted file mode 100644 index 3496665e357..00000000000 --- a/question/engine/upgradefromoldqe/upgrade.php +++ /dev/null @@ -1,1970 +0,0 @@ -. - -/** - * 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 - */ - - -defined('MOODLE_INTERNAL') || die(); - -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, '' . $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 = $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; - - 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); - } - - $qtype = question_bank::get_qtype($question->qtype, false); - if ($qtype->name() === 'missingtype') { - $this->logger->log_assumption("Dealing with question id {$question->id} - that is of an unknown type {$question->qtype}."); - $question->questiontext = '' . get_string('warningmissingtype', 'quiz') . '
' . $question->questiontext; - } - - $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'] = $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 object $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