Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 401 and 402] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * This file contains the code required to upgrade all the attempt data from
  19   * old versions of Moodle into the tables used by the new question engine.
  20   *
  21   * @package    moodlecore
  22   * @subpackage questionengine
  23   * @copyright  2010 The Open University
  24   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  25   */
  26  
  27  
  28  defined('MOODLE_INTERNAL') || die();
  29  
  30  global $CFG;
  31  require_once($CFG->dirroot . '/question/engine/bank.php');
  32  require_once($CFG->dirroot . '/question/engine/upgrade/logger.php');
  33  require_once($CFG->dirroot . '/question/engine/upgrade/behaviourconverters.php');
  34  
  35  
  36  /**
  37   * This class manages upgrading all the question attempts from the old database
  38   * structure to the new question engine.
  39   *
  40   * @copyright  2010 The Open University
  41   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  42   */
  43  class question_engine_attempt_upgrader {
  44      /** @var question_engine_upgrade_question_loader */
  45      protected $questionloader;
  46      /** @var question_engine_assumption_logger */
  47      protected $logger;
  48  
  49      public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
  50          global $OUTPUT;
  51  
  52          $missing = array();
  53  
  54          $layout = explode(',', $attempt->layout);
  55          $questionkeys = array_combine(array_values($layout), array_keys($layout));
  56  
  57          $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
  58  
  59          $i = 0;
  60          foreach (explode(',', $quizlayout) as $questionid) {
  61              if ($questionid == 0) {
  62                  continue;
  63              }
  64              $i++;
  65  
  66              if (!array_key_exists($questionid, $qas)) {
  67                  $missing[] = $questionid;
  68                  $layout[$questionkeys[$questionid]] = $questionid;
  69                  continue;
  70              }
  71  
  72              $qa = $qas[$questionid];
  73              $qa->questionusageid = $attempt->uniqueid;
  74              $qa->slot = $i;
  75              if (core_text::strlen($qa->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
  76                  // It seems some people write very long quesions! MDL-30760
  77                  $qa->questionsummary = core_text::substr($qa->questionsummary,
  78                          0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
  79              }
  80              $this->insert_record('question_attempts', $qa);
  81              $layout[$questionkeys[$questionid]] = $qa->slot;
  82  
  83              foreach ($qa->steps as $step) {
  84                  $step->questionattemptid = $qa->id;
  85                  $this->insert_record('question_attempt_steps', $step);
  86  
  87                  foreach ($step->data as $name => $value) {
  88                      $datum = new stdClass();
  89                      $datum->attemptstepid = $step->id;
  90                      $datum->name = $name;
  91                      $datum->value = $value;
  92                      $this->insert_record('question_attempt_step_data', $datum, false);
  93                  }
  94              }
  95          }
  96  
  97          $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
  98  
  99          if ($missing) {
 100              $message = "Question sessions for questions " .
 101                      implode(', ', $missing) .
 102                      " were missing when upgrading question usage {$attempt->uniqueid}.";
 103              echo $OUTPUT->notification($message);
 104          }
 105      }
 106  
 107      protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
 108          global $DB;
 109          $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
 110                  array('id' => $qubaid));
 111      }
 112  
 113      protected function set_quiz_attempt_layout($qubaid, $layout) {
 114          global $DB;
 115          $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
 116      }
 117  
 118      protected function delete_quiz_attempt($qubaid) {
 119          global $DB;
 120          $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
 121          $DB->delete_records('question_attempts', array('id' => $qubaid));
 122      }
 123  
 124      protected function insert_record($table, $record, $saveid = true) {
 125          global $DB;
 126          $newid = $DB->insert_record($table, $record, $saveid);
 127          if ($saveid) {
 128              $record->id = $newid;
 129          }
 130          return $newid;
 131      }
 132  
 133      public function load_question($questionid, $quizid = null) {
 134          return $this->questionloader->get_question($questionid, $quizid);
 135      }
 136  
 137      public function load_dataset($questionid, $selecteditem) {
 138          return $this->questionloader->load_dataset($questionid, $selecteditem);
 139      }
 140  
 141      public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
 142          if (!$questionsessionsrs->valid()) {
 143              return false;
 144          }
 145  
 146          $qsession = $questionsessionsrs->current();
 147          if ($qsession->attemptid != $attempt->uniqueid) {
 148              // No more question sessions belonging to this attempt.
 149              return false;
 150          }
 151  
 152          // Session found, move the pointer in the RS and return the record.
 153          $questionsessionsrs->next();
 154          return $qsession;
 155      }
 156  
 157      public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
 158          $qstates = array();
 159  
 160          while ($questionsstatesrs->valid()) {
 161              $state = $questionsstatesrs->current();
 162              if ($state->attempt != $attempt->uniqueid ||
 163                      $state->question != $question->id) {
 164                  // We have found all the states for this attempt. Stop.
 165                  break;
 166              }
 167  
 168              // Add the new state to the array, and advance.
 169              $qstates[] = $state;
 170              $questionsstatesrs->next();
 171          }
 172  
 173          return $qstates;
 174      }
 175  
 176      protected function get_converter_class_name($question, $quiz, $qsessionid) {
 177          global $DB;
 178          if ($question->qtype == 'deleted') {
 179              $where = '(question = :questionid OR '.$DB->sql_like('answer', ':randomid').') AND event = 7';
 180              $params = array('questionid'=>$question->id, 'randomid'=>"random{$question->id}-%");
 181              if ($DB->record_exists_select('question_states', $where, $params)) {
 182                  $this->logger->log_assumption("Assuming that deleted question {$question->id} was manually graded.");
 183                  return 'qbehaviour_manualgraded_converter';
 184              }
 185          }
 186          $qtype = question_bank::get_qtype($question->qtype, false);
 187          if ($qtype->is_manual_graded()) {
 188              return 'qbehaviour_manualgraded_converter';
 189          } else if ($question->qtype == 'description') {
 190              return 'qbehaviour_informationitem_converter';
 191          } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
 192              return 'qbehaviour_deferredfeedback_converter';
 193          } else if ($quiz->preferredbehaviour == 'adaptive') {
 194              return 'qbehaviour_adaptive_converter';
 195          } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
 196              return 'qbehaviour_adaptivenopenalty_converter';
 197          } else {
 198              throw new coding_exception("Question session {$qsessionid}
 199                      has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
 200          }
 201      }
 202  
 203      public function supply_missing_question_attempt($quiz, $attempt, $question) {
 204          if ($question->qtype == 'random') {
 205              throw new coding_exception("Cannot supply a missing qsession for question
 206                      {$question->id} in attempt {$attempt->id}.");
 207          }
 208  
 209          $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
 210  
 211          $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
 212                  null, null, $this->logger, $this);
 213          $qa = $qbehaviourupdater->supply_missing_qa();
 214          $qbehaviourupdater->discard();
 215          return $qa;
 216      }
 217  
 218      public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
 219  
 220          if ($question->qtype == 'random') {
 221              list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
 222              $qsession->questionid = $question->id;
 223          }
 224  
 225          $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
 226  
 227          $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
 228                  $qstates, $this->logger, $this);
 229          $qa = $qbehaviourupdater->get_converted_qa();
 230          $qbehaviourupdater->discard();
 231          return $qa;
 232      }
 233  
 234      protected function decode_random_attempt($qstates, $maxmark) {
 235          $realquestionid = null;
 236          foreach ($qstates as $i => $state) {
 237              if (strpos($state->answer, '-') < 6) {
 238                  // Broken state, skip it.
 239                  $this->logger->log_assumption("Had to skip brokes state {$state->id}
 240                          for question {$state->question}.");
 241                  unset($qstates[$i]);
 242                  continue;
 243              }
 244              list($randombit, $realanswer) = explode('-', $state->answer, 2);
 245              $newquestionid = substr($randombit, 6);
 246              if ($realquestionid && $realquestionid != $newquestionid) {
 247                  throw new coding_exception("Question session {$this->qsession->id}
 248                          for random question points to two different real questions
 249                          {$realquestionid} and {$newquestionid}.");
 250              }
 251              $qstates[$i]->answer = $realanswer;
 252          }
 253  
 254          if (empty($newquestionid)) {
 255              // This attempt only had broken states. Set a fake $newquestionid to
 256              // prevent a null DB error later.
 257              $newquestionid = 0;
 258          }
 259  
 260          $newquestion = $this->load_question($newquestionid);
 261          $newquestion->maxmark = $maxmark;
 262          return array($newquestion, $qstates);
 263      }
 264  
 265      public function prepare_to_restore() {
 266          $this->logger = new dummy_question_engine_assumption_logger();
 267          $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
 268      }
 269  }
 270  
 271  
 272  /**
 273   * This class deals with loading (and caching) question definitions during the
 274   * question engine upgrade.
 275   *
 276   * @copyright  2010 The Open University
 277   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 278   */
 279  class question_engine_upgrade_question_loader {
 280      protected $cache = array();
 281      protected $datasetcache = array();
 282  
 283      public function __construct($logger) {
 284          $this->logger = $logger;
 285      }
 286  
 287      protected function load_question($questionid, $quizid) {
 288          global $DB;
 289  
 290          if ($quizid) {
 291              $question = $DB->get_record_sql("
 292                  SELECT q.*, slot.maxmark
 293                  FROM {question} q
 294                  JOIN {quiz_slots} slot ON slot.questionid = q.id
 295                  WHERE q.id = ? AND slot.quizid = ?", array($questionid, $quizid));
 296          } else {
 297              $question = $DB->get_record('question', array('id' => $questionid));
 298          }
 299  
 300          if (!$question) {
 301              return null;
 302          }
 303  
 304          if (empty($question->defaultmark)) {
 305              if (!empty($question->defaultgrade)) {
 306                  $question->defaultmark = $question->defaultgrade;
 307              } else {
 308                  $question->defaultmark = 0;
 309              }
 310              unset($question->defaultgrade);
 311          }
 312  
 313          $qtype = question_bank::get_qtype($question->qtype, false);
 314          if ($qtype->name() === 'missingtype') {
 315              $this->logger->log_assumption("Dealing with question id {$question->id}
 316                      that is of an unknown type {$question->qtype}.");
 317              $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
 318                      '</p>' . $question->questiontext;
 319          }
 320  
 321          $qtype->get_question_options($question);
 322  
 323          return $question;
 324      }
 325  
 326      public function get_question($questionid, $quizid) {
 327          if (isset($this->cache[$questionid])) {
 328              return $this->cache[$questionid];
 329          }
 330  
 331          $question = $this->load_question($questionid, $quizid);
 332  
 333          if (!$question) {
 334              $this->logger->log_assumption("Dealing with question id {$questionid}
 335                      that was missing from the database.");
 336              $question = new stdClass();
 337              $question->id = $questionid;
 338              $question->qtype = 'deleted';
 339              $question->maxmark = 1; // Guess, but that is all we can do.
 340              $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
 341          }
 342  
 343          $this->cache[$questionid] = $question;
 344          return $this->cache[$questionid];
 345      }
 346  
 347      public function load_dataset($questionid, $selecteditem) {
 348          global $DB;
 349  
 350          if (isset($this->datasetcache[$questionid][$selecteditem])) {
 351              return $this->datasetcache[$questionid][$selecteditem];
 352          }
 353  
 354          $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
 355                  SELECT qdd.name, qdi.value
 356                    FROM {question_dataset_items} qdi
 357                    JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
 358                    JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
 359                   WHERE qd.question = ?
 360                     AND qdi.itemnumber = ?
 361                  ', array($questionid, $selecteditem));
 362          return $this->datasetcache[$questionid][$selecteditem];
 363      }
 364  }
 365  
 366  
 367  /**
 368   * Base class for the classes that convert the question-type specific bits of
 369   * the attempt data.
 370   *
 371   * @copyright  2010 The Open University
 372   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 373   */
 374  abstract class question_qtype_attempt_updater {
 375      /** @var object the question definition data. */
 376      protected $question;
 377      /** @var question_behaviour_attempt_updater */
 378      protected $updater;
 379      /** @var question_engine_assumption_logger */
 380      protected $logger;
 381      /** @var question_engine_attempt_upgrader */
 382      protected $qeupdater;
 383  
 384      public function __construct($updater, $question, $logger, $qeupdater) {
 385          $this->updater = $updater;
 386          $this->question = $question;
 387          $this->logger = $logger;
 388          $this->qeupdater = $qeupdater;
 389      }
 390  
 391      public function discard() {
 392          // Help the garbage collector, which seems to be struggling.
 393          $this->updater = null;
 394          $this->question = null;
 395          $this->logger = null;
 396          $this->qeupdater = null;
 397      }
 398  
 399      protected function to_text($html) {
 400          return $this->updater->to_text($html);
 401      }
 402  
 403      public function question_summary() {
 404          return $this->to_text($this->question->questiontext);
 405      }
 406  
 407      public function compare_answers($answer1, $answer2) {
 408          return $answer1 == $answer2;
 409      }
 410  
 411      public function is_blank_answer($state) {
 412          return $state->answer == '';
 413      }
 414  
 415      public abstract function right_answer();
 416      public abstract function response_summary($state);
 417      public abstract function was_answered($state);
 418      public abstract function set_first_step_data_elements($state, &$data);
 419      public abstract function set_data_elements_for_step($state, &$data);
 420      public abstract function supply_missing_first_step_data(&$data);
 421  }
 422  
 423  
 424  class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
 425      public function right_answer() {
 426          return '';
 427      }
 428  
 429      public function response_summary($state) {
 430          return $state->answer;
 431      }
 432  
 433      public function was_answered($state) {
 434          return !empty($state->answer);
 435      }
 436  
 437      public function set_first_step_data_elements($state, &$data) {
 438          $data['upgradedfromdeletedquestion'] = $state->answer;
 439      }
 440  
 441      public function supply_missing_first_step_data(&$data) {
 442      }
 443  
 444      public function set_data_elements_for_step($state, &$data) {
 445          $data['upgradedfromdeletedquestion'] = $state->answer;
 446      }
 447  }
 448  
 449  /**
 450   * This check verifies that all quiz attempts were upgraded since following
 451   * the question engine upgrade in Moodle 2.1.
 452   *
 453   * Note: This custom check (and its environment.xml declaration) will be safely
 454   *       removed once we raise min required Moodle version to be 2.7 or newer.
 455   *
 456   * @param environment_results object to update, if relevant.
 457   * @return environment_results updated results object, or null if this test is not relevant.
 458   */
 459  function quiz_attempts_upgraded(environment_results $result) {
 460      global $DB;
 461  
 462      $dbman = $DB->get_manager();
 463      $table = new xmldb_table('quiz_attempts');
 464      $field = new xmldb_field('needsupgradetonewqe');
 465  
 466      if (!$dbman->table_exists($table) || !$dbman->field_exists($table, $field)) {
 467          // DB already upgraded. This test is no longer relevant.
 468          return null;
 469      }
 470  
 471      if (!$DB->record_exists('quiz_attempts', array('needsupgradetonewqe' => 1))) {
 472          // No 1s present in that column means there are no problems.
 473          return null;
 474      }
 475  
 476      // Only display anything if the admins need to be aware of the problem.
 477      $result->setStatus(false);
 478      return $result;
 479  }