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.
   1  <?php
   2  
   3  // This file is part of Moodle - http://moodle.org/
   4  //
   5  // Moodle is free software: you can redistribute it and/or modify
   6  // it under the terms of the GNU General Public License as published by
   7  // the Free Software Foundation, either version 3 of the License, or
   8  // (at your option) any later version.
   9  //
  10  // Moodle is distributed in the hope that it will be useful,
  11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  13  // GNU General Public License for more details.
  14  //
  15  // You should have received a copy of the GNU General Public License
  16  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  17  
  18  /**
  19   * Defines restore_qtype_plugin class
  20   *
  21   * @package     core_backup
  22   * @subpackage  moodle2
  23   * @category    backup
  24   * @copyright   2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
  25   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  26   */
  27  
  28  defined('MOODLE_INTERNAL') || die();
  29  
  30  /**
  31   * Class extending standard restore_plugin in order to implement some
  32   * helper methods related with the questions (qtype plugin)
  33   *
  34   * TODO: Finish phpdocs
  35   */
  36  abstract class restore_qtype_plugin extends restore_plugin {
  37  
  38      /*
  39       * A simple answer to id cache for a single questions answers.
  40       * @var array
  41       */
  42      private $questionanswercache = array();
  43  
  44      /*
  45       * The id of the current question in the questionanswercache.
  46       * @var int
  47       */
  48      private $questionanswercacheid = null;
  49  
  50      /**
  51       * Add to $paths the restore_path_elements needed
  52       * to handle question_answers for a given question
  53       * Used by various qtypes (calculated, essay, multianswer,
  54       * multichoice, numerical, shortanswer, truefalse)
  55       */
  56      protected function add_question_question_answers(&$paths) {
  57          // Check $paths is one array
  58          if (!is_array($paths)) {
  59              throw new restore_step_exception('paths_must_be_array', $paths);
  60          }
  61  
  62          $elename = 'question_answer';
  63          $elepath = $this->get_pathfor('/answers/answer'); // we used get_recommended_name() so this works
  64          $paths[] = new restore_path_element($elename, $elepath);
  65      }
  66  
  67      /**
  68       * Add to $paths the restore_path_elements needed
  69       * to handle question_numerical_units for a given question
  70       * Used by various qtypes (calculated, numerical)
  71       */
  72      protected function add_question_numerical_units(&$paths) {
  73          // Check $paths is one array
  74          if (!is_array($paths)) {
  75              throw new restore_step_exception('paths_must_be_array', $paths);
  76          }
  77  
  78          $elename = 'question_numerical_unit';
  79          $elepath = $this->get_pathfor('/numerical_units/numerical_unit'); // we used get_recommended_name() so this works
  80          $paths[] = new restore_path_element($elename, $elepath);
  81      }
  82  
  83      /**
  84       * Add to $paths the restore_path_elements needed
  85       * to handle question_numerical_options for a given question
  86       * Used by various qtypes (calculated, numerical)
  87       */
  88      protected function add_question_numerical_options(&$paths) {
  89          // Check $paths is one array
  90          if (!is_array($paths)) {
  91              throw new restore_step_exception('paths_must_be_array', $paths);
  92          }
  93  
  94          $elename = 'question_numerical_option';
  95          $elepath = $this->get_pathfor('/numerical_options/numerical_option'); // we used get_recommended_name() so this works
  96          $paths[] = new restore_path_element($elename, $elepath);
  97      }
  98  
  99      /**
 100       * Add to $paths the restore_path_elements needed
 101       * to handle question_datasets (defs and items) for a given question
 102       * Used by various qtypes (calculated, numerical)
 103       */
 104      protected function add_question_datasets(&$paths) {
 105          // Check $paths is one array
 106          if (!is_array($paths)) {
 107              throw new restore_step_exception('paths_must_be_array', $paths);
 108          }
 109  
 110          $elename = 'question_dataset_definition';
 111          $elepath = $this->get_pathfor('/dataset_definitions/dataset_definition'); // we used get_recommended_name() so this works
 112          $paths[] = new restore_path_element($elename, $elepath);
 113  
 114          $elename = 'question_dataset_item';
 115          $elepath = $this->get_pathfor('/dataset_definitions/dataset_definition/dataset_items/dataset_item');
 116          $paths[] = new restore_path_element($elename, $elepath);
 117      }
 118  
 119      /**
 120       * Processes the answer element (question answers). Common for various qtypes.
 121       * It handles both creation (if the question is being created) and mapping
 122       * (if the question already existed and is being reused)
 123       */
 124      public function process_question_answer($data) {
 125          global $DB;
 126  
 127          $data = (object)$data;
 128          $oldid = $data->id;
 129  
 130          // Detect if the question is created or mapped
 131          $oldquestionid   = $this->get_old_parentid('question');
 132          $newquestionid   = $this->get_new_parentid('question');
 133          $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false;
 134  
 135          // In the past, there were some sloppily rounded fractions around. Fix them up.
 136          $changes = array(
 137              '-0.66666'  => '-0.6666667',
 138              '-0.33333'  => '-0.3333333',
 139              '-0.16666'  => '-0.1666667',
 140              '-0.142857' => '-0.1428571',
 141               '0.11111'  =>  '0.1111111',
 142               '0.142857' =>  '0.1428571',
 143               '0.16666'  =>  '0.1666667',
 144               '0.33333'  =>  '0.3333333',
 145               '0.333333' =>  '0.3333333',
 146               '0.66666'  =>  '0.6666667',
 147          );
 148          if (array_key_exists($data->fraction, $changes)) {
 149              $data->fraction = $changes[$data->fraction];
 150          }
 151  
 152          // If the question has been created by restore, we need to create its question_answers too
 153          if ($questioncreated) {
 154              // Adjust some columns
 155              $data->question = $newquestionid;
 156              $data->answer = $data->answertext;
 157              // Insert record
 158              $newitemid = $DB->insert_record('question_answers', $data);
 159  
 160          // The question existed, we need to map the existing question_answers
 161          } else {
 162              // Have we cached the current question?
 163              if ($this->questionanswercacheid !== $newquestionid) {
 164                  // The question changed, purge and start again!
 165                  $this->questionanswercache = array();
 166                  $params = array('question' => $newquestionid);
 167                  $answers = $DB->get_records('question_answers', $params, '', 'id, answer');
 168                  $this->questionanswercacheid = $newquestionid;
 169                  // Cache all cleaned answers for a simple text match.
 170                  foreach ($answers as $answer) {
 171                      // MDL-30018: Clean in the same way as {@link xml_writer::xml_safe_utf8()}.
 172                      $clean = preg_replace('/[\x-\x8\xb-\xc\xe-\x1f\x7f]/is','', $answer->answer); // Clean CTRL chars.
 173                      $clean = preg_replace("/\r\n|\r/", "\n", $clean); // Normalize line ending.
 174                      $this->questionanswercache[$clean] = $answer->id;
 175                  }
 176              }
 177  
 178              $rules = restore_course_task::define_decode_rules();
 179              $rulesactivity = restore_quiz_activity_task::define_decode_rules();
 180              $rules = array_merge($rules, $rulesactivity);
 181  
 182              $decoder = $this->task->get_decoder();
 183              foreach ($rules as $rule) {
 184                  $decoder->add_rule($rule);
 185              }
 186  
 187              $contentdecoded = $decoder->decode_content($data->answertext);
 188              if ($contentdecoded) {
 189                  $data->answertext = $contentdecoded;
 190              }
 191  
 192              if (!isset($this->questionanswercache[$data->answertext])) {
 193                  // If we haven't found the matching answer, something has gone really wrong, the question in the DB
 194                  // is missing answers, throw an exception.
 195                  $info = new stdClass();
 196                  $info->filequestionid = $oldquestionid;
 197                  $info->dbquestionid   = $newquestionid;
 198                  $info->answer         = s($data->answertext);
 199                  throw new restore_step_exception('error_question_answers_missing_in_db', $info);
 200              }
 201              $newitemid = $this->questionanswercache[$data->answertext];
 202          }
 203          // Create mapping (we'll use this intensively when restoring question_states. And also answerfeedback files)
 204          $this->set_mapping('question_answer', $oldid, $newitemid);
 205      }
 206  
 207      /**
 208       * Processes the numerical_unit element (question numerical units). Common for various qtypes.
 209       * It handles both creation (if the question is being created) and mapping
 210       * (if the question already existed and is being reused)
 211       */
 212      public function process_question_numerical_unit($data) {
 213          global $DB;
 214  
 215          $data = (object)$data;
 216          $oldid = $data->id;
 217  
 218          // Detect if the question is created or mapped
 219          $oldquestionid   = $this->get_old_parentid('question');
 220          $newquestionid   = $this->get_new_parentid('question');
 221          $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false;
 222  
 223          // If the question has been created by restore, we need to create its question_numerical_units too
 224          if ($questioncreated) {
 225              // Adjust some columns
 226              $data->question = $newquestionid;
 227              // Insert record
 228              $newitemid = $DB->insert_record('question_numerical_units', $data);
 229          }
 230      }
 231  
 232      /**
 233       * Processes the numerical_option element (question numerical options). Common for various qtypes.
 234       * It handles both creation (if the question is being created) and mapping
 235       * (if the question already existed and is being reused)
 236       */
 237      public function process_question_numerical_option($data) {
 238          global $DB;
 239  
 240          $data = (object)$data;
 241          $oldid = $data->id;
 242  
 243          // Detect if the question is created or mapped
 244          $oldquestionid   = $this->get_old_parentid('question');
 245          $newquestionid   = $this->get_new_parentid('question');
 246          $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false;
 247  
 248          // If the question has been created by restore, we need to create its question_numerical_options too
 249          if ($questioncreated) {
 250              // Adjust some columns
 251              $data->question = $newquestionid;
 252              // Insert record
 253              $newitemid = $DB->insert_record('question_numerical_options', $data);
 254              // Create mapping (not needed, no files nor childs nor states here)
 255              //$this->set_mapping('question_numerical_option', $oldid, $newitemid);
 256          }
 257      }
 258  
 259      /**
 260       * Processes the dataset_definition element (question dataset definitions). Common for various qtypes.
 261       * It handles both creation (if the question is being created) and mapping
 262       * (if the question already existed and is being reused)
 263       */
 264      public function process_question_dataset_definition($data) {
 265          global $DB;
 266  
 267          $data = (object)$data;
 268          $oldid = $data->id;
 269  
 270          // Detect if the question is created or mapped
 271          $oldquestionid   = $this->get_old_parentid('question');
 272          $newquestionid   = $this->get_new_parentid('question');
 273          $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false;
 274  
 275          // If the question is mapped, nothing to do
 276          if (!$questioncreated) {
 277              return;
 278          }
 279  
 280          // Arrived here, let's see if the question_dataset_definition already exists in category or no
 281          // (by category, name, type and enough items). Only for "shared" definitions (category != 0).
 282          // If exists, reuse it, else, create it as "not shared" (category = 0)
 283          $data->category = $this->get_mappingid('question_category', $data->category);
 284          // If category is shared, look for definitions
 285          $founddefid = null;
 286          if ($data->category) {
 287              $candidatedefs = $DB->get_records_sql("SELECT id, itemcount
 288                                                       FROM {question_dataset_definitions}
 289                                                      WHERE category = ?
 290                                                        AND name = ?
 291                                                        AND type = ?", array($data->category, $data->name, $data->type));
 292              foreach ($candidatedefs as $candidatedef) {
 293                  if ($candidatedef->itemcount >= $data->itemcount) { // Check it has enough items
 294                      $founddefid = $candidatedef->id;
 295                      break; // end loop, shared definition match found
 296                  }
 297              }
 298              // If there were candidates but none fulfilled the itemcount condition, create definition as not shared
 299              if ($candidatedefs && !$founddefid) {
 300                  $data->category = 0;
 301              }
 302          }
 303          // If haven't found any shared definition match, let's create it
 304          if (!$founddefid) {
 305              $newitemid = $DB->insert_record('question_dataset_definitions', $data);
 306              // Set mapping, so dataset items will know if they must be created
 307              $this->set_mapping('question_dataset_definition', $oldid, $newitemid);
 308  
 309          // If we have found one shared definition match, use it
 310          } else {
 311              $newitemid = $founddefid;
 312              // Set mapping to 0, so dataset items will know they don't need to be created
 313              $this->set_mapping('question_dataset_definition', $oldid, 0);
 314          }
 315  
 316          // Arrived here, we have one $newitemid (create or reused). Create the question_datasets record
 317          $questiondataset = new stdClass();
 318          $questiondataset->question = $newquestionid;
 319          $questiondataset->datasetdefinition = $newitemid;
 320          $DB->insert_record('question_datasets', $questiondataset);
 321      }
 322  
 323      /**
 324       * Processes the dataset_item element (question dataset items). Common for various qtypes.
 325       * It handles both creation (if the question is being created) and mapping
 326       * (if the question already existed and is being reused)
 327       */
 328      public function process_question_dataset_item($data) {
 329          global $DB;
 330  
 331          $data = (object)$data;
 332          $oldid = $data->id;
 333  
 334          // Detect if the question is created or mapped
 335          $oldquestionid   = $this->get_old_parentid('question');
 336          $newquestionid   = $this->get_new_parentid('question');
 337          $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false;
 338  
 339          // If the question is mapped, nothing to do
 340          if (!$questioncreated) {
 341              return;
 342          }
 343  
 344          // Detect if the question_dataset_definition is being created
 345          $newdefinitionid = $this->get_new_parentid('question_dataset_definition');
 346  
 347          // If the definition is reused, nothing to do
 348          if (!$newdefinitionid) {
 349              return;
 350          }
 351  
 352          // let's create the question_dataset_items
 353          $data->definition = $newdefinitionid;
 354          $data->itemnumber = $data->number;
 355          $DB->insert_record('question_dataset_items', $data);
 356      }
 357  
 358      /**
 359       * Do any re-coding necessary in the student response.
 360       * @param int $questionid the new id of the question
 361       * @param int $sequencenumber of the step within the qusetion attempt.
 362       * @param array the response data from the backup.
 363       * @return array the recoded response.
 364       */
 365      public function recode_response($questionid, $sequencenumber, array $response) {
 366          return $response;
 367      }
 368  
 369      /**
 370       * Decode legacy question_states.answer for this qtype. Used when restoring
 371       * 2.0 attempt data.
 372       */
 373      public function recode_legacy_state_answer($state) {
 374          // By default, return answer unmodified, qtypes needing recode will override this
 375          return $state->answer;
 376      }
 377  
 378      /**
 379       * Return the contents of the questions stuff that must be processed by the links decoder
 380       *
 381       * Only common stuff to all plugins, in this case:
 382       * - question: text and feedback
 383       * - question_answers: text and feedbak
 384       *
 385       * Note each qtype will have, if needed, its own define_decode_contents method
 386       */
 387      static public function define_plugin_decode_contents() {
 388  
 389          $contents = array();
 390  
 391          $contents[] = new restore_decode_content('question', array('questiontext', 'generalfeedback'), 'question_created');
 392          $contents[] = new restore_decode_content('question_answers', array('answer', 'feedback'), 'question_answer');
 393  
 394          return $contents;
 395      }
 396  }