Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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