|
Moodle
2.2.1
http://www.collinsharper.com
|
00001 <?php 00002 // This file is part of Moodle - http://moodle.org/ 00003 // 00004 // Moodle is free software: you can redistribute it and/or modify 00005 // it under the terms of the GNU General Public License as published by 00006 // the Free Software Foundation, either version 3 of the License, or 00007 // (at your option) any later version. 00008 // 00009 // Moodle is distributed in the hope that it will be useful, 00010 // but WITHOUT ANY WARRANTY; without even the implied warranty of 00011 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 00012 // GNU General Public License for more details. 00013 // 00014 // You should have received a copy of the GNU General Public License 00015 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 00016 00028 defined('MOODLE_INTERNAL') || die(); 00029 00030 global $CFG; 00031 require_once($CFG->dirroot . '/question/engine/bank.php'); 00032 require_once($CFG->dirroot . '/question/engine/upgrade/logger.php'); 00033 require_once($CFG->dirroot . '/question/engine/upgrade/behaviourconverters.php'); 00034 00035 00043 class question_engine_attempt_upgrader { 00045 protected $questionloader; 00047 protected $logger; 00049 protected $dotcounter = 0; 00051 protected $progressbar = null; 00053 protected $doingbackup = false; 00054 00061 protected function print_progress($done, $outof, $quizid) { 00062 if (is_null($this->progressbar)) { 00063 $this->progressbar = new progress_bar('qe2upgrade'); 00064 $this->progressbar->create(); 00065 } 00066 00067 gc_collect_cycles(); // This was really helpful in PHP 5.2. Perhaps remove. 00068 $a = new stdClass(); 00069 $a->done = $done; 00070 $a->outof = $outof; 00071 $a->info = $quizid; 00072 $this->progressbar->update($done, $outof, get_string('upgradingquizattempts', 'quiz', $a)); 00073 } 00074 00075 protected function prevent_timeout() { 00076 set_time_limit(300); 00077 if ($this->doingbackup) { 00078 return; 00079 } 00080 echo '.'; 00081 $this->dotcounter += 1; 00082 if ($this->dotcounter % 100 == 0) { 00083 echo '<br />'; 00084 } 00085 } 00086 00087 protected function get_quiz_ids() { 00088 global $CFG, $DB; 00089 00090 // Look to see if the admin has set things up to only upgrade certain attempts. 00091 $partialupgradefile = $CFG->dirroot . '/' . $CFG->admin . 00092 '/tool/qeupgradehelper/partialupgrade.php'; 00093 $partialupgradefunction = 'tool_qeupgradehelper_get_quizzes_to_upgrade'; 00094 if (is_readable($partialupgradefile)) { 00095 include_once($partialupgradefile); 00096 if (function_exists($partialupgradefunction)) { 00097 $quizids = $partialupgradefunction(); 00098 00099 // Ignore any quiz ids that do not acually exist. 00100 if (empty($quizids)) { 00101 return array(); 00102 } 00103 list($test, $params) = $DB->get_in_or_equal($quizids); 00104 return $DB->get_fieldset_sql(" 00105 SELECT id 00106 FROM {quiz} 00107 WHERE id $test 00108 ORDER BY id", $params); 00109 } 00110 } 00111 00112 // Otherwise, upgrade all attempts. 00113 return $DB->get_fieldset_sql('SELECT id FROM {quiz} ORDER BY id'); 00114 } 00115 00116 public function convert_all_quiz_attempts() { 00117 global $DB; 00118 00119 $quizids = $this->get_quiz_ids(); 00120 if (empty($quizids)) { 00121 return true; 00122 } 00123 00124 $done = 0; 00125 $outof = count($quizids); 00126 $this->logger = new question_engine_assumption_logger(); 00127 00128 foreach ($quizids as $quizid) { 00129 $this->print_progress($done, $outof, $quizid); 00130 00131 $quiz = $DB->get_record('quiz', array('id' => $quizid), '*', MUST_EXIST); 00132 $this->update_all_attempts_at_quiz($quiz); 00133 00134 $done += 1; 00135 } 00136 00137 $this->print_progress($outof, $outof, 'All done!'); 00138 $this->logger = null; 00139 } 00140 00141 public function get_attempts_extra_where() { 00142 return ' AND needsupgradetonewqe = 1'; 00143 } 00144 00145 public function update_all_attempts_at_quiz($quiz) { 00146 global $DB; 00147 00148 // Wipe question loader cache. 00149 $this->questionloader = new question_engine_upgrade_question_loader($this->logger); 00150 00151 $transaction = $DB->start_delegated_transaction(); 00152 00153 $params = array('quizid' => $quiz->id); 00154 $where = 'quiz = :quizid AND preview = 0' . $this->get_attempts_extra_where(); 00155 00156 $quizattemptsrs = $DB->get_recordset_select('quiz_attempts', $where, $params, 'uniqueid'); 00157 $questionsessionsrs = $DB->get_recordset_sql(" 00158 SELECT s.* 00159 FROM {question_sessions} s 00160 JOIN {quiz_attempts} a ON (attemptid = uniqueid) 00161 WHERE $where 00162 ORDER BY attemptid, questionid 00163 ", $params); 00164 00165 $questionsstatesrs = $DB->get_recordset_sql(" 00166 SELECT s.* 00167 FROM {question_states} s 00168 JOIN {quiz_attempts} ON (s.attempt = uniqueid) 00169 WHERE $where 00170 ORDER BY s.attempt, question, seq_number, s.id 00171 ", $params); 00172 00173 $datatodo = $quizattemptsrs && $questionsessionsrs && $questionsstatesrs; 00174 while ($datatodo && $quizattemptsrs->valid()) { 00175 $attempt = $quizattemptsrs->current(); 00176 $quizattemptsrs->next(); 00177 $this->convert_quiz_attempt($quiz, $attempt, $questionsessionsrs, $questionsstatesrs); 00178 } 00179 00180 $quizattemptsrs->close(); 00181 $questionsessionsrs->close(); 00182 $questionsstatesrs->close(); 00183 00184 $transaction->allow_commit(); 00185 } 00186 00187 protected function convert_quiz_attempt($quiz, $attempt, moodle_recordset $questionsessionsrs, 00188 moodle_recordset $questionsstatesrs) { 00189 $qas = array(); 00190 $this->logger->set_current_attempt_id($attempt->id); 00191 while ($qsession = $this->get_next_question_session($attempt, $questionsessionsrs)) { 00192 $question = $this->load_question($qsession->questionid, $quiz->id); 00193 $qstates = $this->get_question_states($attempt, $question, $questionsstatesrs); 00194 try { 00195 $qas[$qsession->questionid] = $this->convert_question_attempt( 00196 $quiz, $attempt, $question, $qsession, $qstates); 00197 } catch (Exception $e) { 00198 notify($e->getMessage()); 00199 } 00200 } 00201 $this->logger->set_current_attempt_id(null); 00202 00203 $questionorder = array(); 00204 foreach (explode(',', $quiz->questions) as $questionid) { 00205 if ($questionid == 0) { 00206 continue; 00207 } 00208 if (!array_key_exists($questionid, $qas)) { 00209 $this->logger->log_assumption("Supplying minimal open state for 00210 question {$questionid} in attempt {$attempt->id} at quiz 00211 {$attempt->quiz}, since the session was missing.", $attempt->id); 00212 try { 00213 $question = $this->load_question($questionid, $quiz->id); 00214 $qas[$questionid] = $this->supply_missing_question_attempt( 00215 $quiz, $attempt, $question); 00216 } catch (Exception $e) { 00217 notify($e->getMessage()); 00218 } 00219 } 00220 } 00221 00222 return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions); 00223 } 00224 00225 public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) { 00226 $missing = array(); 00227 00228 $layout = explode(',', $attempt->layout); 00229 $questionkeys = array_combine(array_values($layout), array_keys($layout)); 00230 00231 $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour); 00232 00233 $i = 0; 00234 foreach (explode(',', $quizlayout) as $questionid) { 00235 if ($questionid == 0) { 00236 continue; 00237 } 00238 $i++; 00239 00240 if (!array_key_exists($questionid, $qas)) { 00241 $missing[] = $questionid; 00242 $layout[$questionkeys[$questionid]] = $questionid; 00243 continue; 00244 } 00245 00246 $qa = $qas[$questionid]; 00247 $qa->questionusageid = $attempt->uniqueid; 00248 $qa->slot = $i; 00249 if (textlib::strlen($qa->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) { 00250 // It seems some people write very long quesions! MDL-30760 00251 $qa->questionsummary = textlib::substr($qa->questionsummary, 00252 0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...'; 00253 } 00254 $this->insert_record('question_attempts', $qa); 00255 $layout[$questionkeys[$questionid]] = $qa->slot; 00256 00257 foreach ($qa->steps as $step) { 00258 $step->questionattemptid = $qa->id; 00259 $this->insert_record('question_attempt_steps', $step); 00260 00261 foreach ($step->data as $name => $value) { 00262 $datum = new stdClass(); 00263 $datum->attemptstepid = $step->id; 00264 $datum->name = $name; 00265 $datum->value = $value; 00266 $this->insert_record('question_attempt_step_data', $datum, false); 00267 } 00268 } 00269 } 00270 00271 $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout)); 00272 00273 if ($missing) { 00274 notify("Question sessions for questions " . 00275 implode(', ', $missing) . 00276 " were missing when upgrading question usage {$attempt->uniqueid}."); 00277 } 00278 } 00279 00280 protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) { 00281 global $DB; 00282 $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour, 00283 array('id' => $qubaid)); 00284 } 00285 00286 protected function set_quiz_attempt_layout($qubaid, $layout) { 00287 global $DB; 00288 $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid)); 00289 $DB->set_field('quiz_attempts', 'needsupgradetonewqe', 0, array('uniqueid' => $qubaid)); 00290 } 00291 00292 protected function delete_quiz_attempt($qubaid) { 00293 global $DB; 00294 $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid)); 00295 $DB->delete_records('question_attempts', array('id' => $qubaid)); 00296 } 00297 00298 protected function insert_record($table, $record, $saveid = true) { 00299 global $DB; 00300 $newid = $DB->insert_record($table, $record, $saveid); 00301 if ($saveid) { 00302 $record->id = $newid; 00303 } 00304 return $newid; 00305 } 00306 00307 public function load_question($questionid, $quizid = null) { 00308 return $this->questionloader->get_question($questionid, $quizid); 00309 } 00310 00311 public function load_dataset($questionid, $selecteditem) { 00312 return $this->questionloader->load_dataset($questionid, $selecteditem); 00313 } 00314 00315 public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) { 00316 if (!$questionsessionsrs->valid()) { 00317 return false; 00318 } 00319 00320 $qsession = $questionsessionsrs->current(); 00321 if ($qsession->attemptid != $attempt->uniqueid) { 00322 // No more question sessions belonging to this attempt. 00323 return false; 00324 } 00325 00326 // Session found, move the pointer in the RS and return the record. 00327 $questionsessionsrs->next(); 00328 return $qsession; 00329 } 00330 00331 public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) { 00332 $qstates = array(); 00333 00334 while ($questionsstatesrs->valid()) { 00335 $state = $questionsstatesrs->current(); 00336 if ($state->attempt != $attempt->uniqueid || 00337 $state->question != $question->id) { 00338 // We have found all the states for this attempt. Stop. 00339 break; 00340 } 00341 00342 // Add the new state to the array, and advance. 00343 $qstates[] = $state; 00344 $questionsstatesrs->next(); 00345 } 00346 00347 return $qstates; 00348 } 00349 00350 protected function get_converter_class_name($question, $quiz, $qsessionid) { 00351 global $DB; 00352 if ($question->qtype == 'deleted') { 00353 $where = '(question = :questionid OR '.$DB->sql_like('answer', ':randomid').') AND event = 7'; 00354 $params = array('questionid'=>$question->id, 'randomid'=>"random{$question->id}-%"); 00355 if ($DB->record_exists_select('question_states', $where, $params)) { 00356 $this->logger->log_assumption("Assuming that deleted question {$question->id} was manually graded."); 00357 return 'qbehaviour_manualgraded_converter'; 00358 } 00359 } 00360 if ($question->qtype == 'essay') { 00361 return 'qbehaviour_manualgraded_converter'; 00362 } else if ($question->qtype == 'description') { 00363 return 'qbehaviour_informationitem_converter'; 00364 } else if ($quiz->preferredbehaviour == 'deferredfeedback') { 00365 return 'qbehaviour_deferredfeedback_converter'; 00366 } else if ($quiz->preferredbehaviour == 'adaptive') { 00367 return 'qbehaviour_adaptive_converter'; 00368 } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') { 00369 return 'qbehaviour_adaptivenopenalty_converter'; 00370 } else { 00371 throw new coding_exception("Question session {$qsessionid} 00372 has an unexpected preferred behaviour {$quiz->preferredbehaviour}."); 00373 } 00374 } 00375 00376 public function supply_missing_question_attempt($quiz, $attempt, $question) { 00377 if ($question->qtype == 'random') { 00378 throw new coding_exception("Cannot supply a missing qsession for question 00379 {$question->id} in attempt {$attempt->id}."); 00380 } 00381 00382 $converterclass = $this->get_converter_class_name($question, $quiz, 'missing'); 00383 00384 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, 00385 null, null, $this->logger, $this); 00386 $qa = $qbehaviourupdater->supply_missing_qa(); 00387 $qbehaviourupdater->discard(); 00388 return $qa; 00389 } 00390 00391 public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) { 00392 $this->prevent_timeout(); 00393 00394 if ($question->qtype == 'random') { 00395 list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark); 00396 $qsession->questionid = $question->id; 00397 } 00398 00399 $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id); 00400 00401 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession, 00402 $qstates, $this->logger, $this); 00403 $qa = $qbehaviourupdater->get_converted_qa(); 00404 $qbehaviourupdater->discard(); 00405 return $qa; 00406 } 00407 00408 protected function decode_random_attempt($qstates, $maxmark) { 00409 $realquestionid = null; 00410 foreach ($qstates as $i => $state) { 00411 if (strpos($state->answer, '-') < 6) { 00412 // Broken state, skip it. 00413 $this->logger->log_assumption("Had to skip brokes state {$state->id} 00414 for question {$state->question}."); 00415 unset($qstates[$i]); 00416 continue; 00417 } 00418 list($randombit, $realanswer) = explode('-', $state->answer, 2); 00419 $newquestionid = substr($randombit, 6); 00420 if ($realquestionid && $realquestionid != $newquestionid) { 00421 throw new coding_exception("Question session {$this->qsession->id} 00422 for random question points to two different real questions 00423 {$realquestionid} and {$newquestionid}."); 00424 } 00425 $qstates[$i]->answer = $realanswer; 00426 } 00427 00428 if (empty($newquestionid)) { 00429 // This attempt only had broken states. Set a fake $newquestionid to 00430 // prevent a null DB error later. 00431 $newquestionid = 0; 00432 } 00433 00434 $newquestion = $this->load_question($newquestionid); 00435 $newquestion->maxmark = $maxmark; 00436 return array($newquestion, $qstates); 00437 } 00438 00439 public function prepare_to_restore() { 00440 $this->doingbackup = true; // Prevent printing of dots to stop timeout on upgrade. 00441 $this->logger = new dummy_question_engine_assumption_logger(); 00442 $this->questionloader = new question_engine_upgrade_question_loader($this->logger); 00443 } 00444 } 00445 00446 00454 class question_engine_upgrade_question_loader { 00455 private $cache = array(); 00456 private $datasetcache = array(); 00457 00458 public function __construct($logger) { 00459 $this->logger = $logger; 00460 } 00461 00462 protected function load_question($questionid, $quizid) { 00463 global $DB; 00464 00465 if ($quizid) { 00466 $question = $DB->get_record_sql(" 00467 SELECT q.*, qqi.grade AS maxmark 00468 FROM {question} q 00469 JOIN {quiz_question_instances} qqi ON qqi.question = q.id 00470 WHERE q.id = $questionid AND qqi.quiz = $quizid"); 00471 } else { 00472 $question = $DB->get_record('question', array('id' => $questionid)); 00473 } 00474 00475 if (!$question) { 00476 return null; 00477 } 00478 00479 if (empty($question->defaultmark)) { 00480 if (!empty($question->defaultgrade)) { 00481 $question->defaultmark = $question->defaultgrade; 00482 } else { 00483 $question->defaultmark = 0; 00484 } 00485 unset($question->defaultgrade); 00486 } 00487 00488 $qtype = question_bank::get_qtype($question->qtype, false); 00489 if ($qtype->name() === 'missingtype') { 00490 $this->logger->log_assumption("Dealing with question id {$question->id} 00491 that is of an unknown type {$question->qtype}."); 00492 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . 00493 '</p>' . $question->questiontext; 00494 } 00495 00496 $qtype->get_question_options($question); 00497 00498 return $question; 00499 } 00500 00501 public function get_question($questionid, $quizid) { 00502 if (isset($this->cache[$questionid])) { 00503 return $this->cache[$questionid]; 00504 } 00505 00506 $question = $this->load_question($questionid, $quizid); 00507 00508 if (!$question) { 00509 $this->logger->log_assumption("Dealing with question id {$questionid} 00510 that was missing from the database."); 00511 $question = new stdClass(); 00512 $question->id = $questionid; 00513 $question->qtype = 'deleted'; 00514 $question->maxmark = 1; // Guess, but that is all we can do. 00515 $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype'); 00516 } 00517 00518 $this->cache[$questionid] = $question; 00519 return $this->cache[$questionid]; 00520 } 00521 00522 public function load_dataset($questionid, $selecteditem) { 00523 global $DB; 00524 00525 if (isset($this->datasetcache[$questionid][$selecteditem])) { 00526 return $this->datasetcache[$questionid][$selecteditem]; 00527 } 00528 00529 $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu(' 00530 SELECT qdd.name, qdi.value 00531 FROM {question_dataset_items} qdi 00532 JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition 00533 JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition 00534 WHERE qd.question = ? 00535 AND qdi.itemnumber = ? 00536 ', array($questionid, $selecteditem)); 00537 return $this->datasetcache[$questionid][$selecteditem]; 00538 } 00539 } 00540 00541 00549 abstract class question_qtype_attempt_updater { 00551 protected $question; 00553 protected $updater; 00555 protected $logger; 00557 protected $qeupdater; 00558 00559 public function __construct($updater, $question, $logger, $qeupdater) { 00560 $this->updater = $updater; 00561 $this->question = $question; 00562 $this->logger = $logger; 00563 $this->qeupdater = $qeupdater; 00564 } 00565 00566 public function discard() { 00567 // Help the garbage collector, which seems to be struggling. 00568 $this->updater = null; 00569 $this->question = null; 00570 $this->logger = null; 00571 $this->qeupdater = null; 00572 } 00573 00574 protected function to_text($html) { 00575 return $this->updater->to_text($html); 00576 } 00577 00578 public function question_summary() { 00579 return $this->to_text($this->question->questiontext); 00580 } 00581 00582 public function compare_answers($answer1, $answer2) { 00583 return $answer1 == $answer2; 00584 } 00585 00586 public function is_blank_answer($state) { 00587 return $state->answer == ''; 00588 } 00589 00590 public abstract function right_answer(); 00591 public abstract function response_summary($state); 00592 public abstract function was_answered($state); 00593 public abstract function set_first_step_data_elements($state, &$data); 00594 public abstract function set_data_elements_for_step($state, &$data); 00595 public abstract function supply_missing_first_step_data(&$data); 00596 } 00597 00598 00599 class question_deleted_question_attempt_updater extends question_qtype_attempt_updater { 00600 public function right_answer() { 00601 return ''; 00602 } 00603 00604 public function response_summary($state) { 00605 return $state->answer; 00606 } 00607 00608 public function was_answered($state) { 00609 return !empty($state->answer); 00610 } 00611 00612 public function set_first_step_data_elements($state, &$data) { 00613 $data['upgradedfromdeletedquestion'] = $state->answer; 00614 } 00615 00616 public function supply_missing_first_step_data(&$data) { 00617 } 00618 00619 public function set_data_elements_for_step($state, &$data) { 00620 $data['upgradedfromdeletedquestion'] = $state->answer; 00621 } 00622 }