Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]

   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   * Defines the \mod_quiz\structure class.
  19   *
  20   * @package   mod_quiz
  21   * @copyright 2013 The Open University
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace mod_quiz;
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  /**
  29   * Quiz structure class.
  30   *
  31   * The structure of the quiz. That is, which questions it is built up
  32   * from. This is used on the Edit quiz page (edit.php) and also when
  33   * starting an attempt at the quiz (startattempt.php). Once an attempt
  34   * has been started, then the attempt holds the specific set of questions
  35   * that that student should answer, and we no longer use this class.
  36   *
  37   * @copyright 2014 The Open University
  38   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  class structure {
  41      /** @var \quiz the quiz this is the structure of. */
  42      protected $quizobj = null;
  43  
  44      /**
  45       * @var \stdClass[] the questions in this quiz. Contains the row from the questions
  46       * table, with the data from the quiz_slots table added, and also question_categories.contextid.
  47       */
  48      protected $questions = array();
  49  
  50      /** @var \stdClass[] quiz_slots.slot => the quiz_slots rows for this quiz, agumented by sectionid. */
  51      protected $slotsinorder = array();
  52  
  53      /**
  54       * @var \stdClass[] currently a dummy. Holds data that will match the
  55       * quiz_sections, once it exists.
  56       */
  57      protected $sections = array();
  58  
  59      /** @var bool caches the results of can_be_edited. */
  60      protected $canbeedited = null;
  61  
  62      /** @var bool caches the results of can_add_random_question. */
  63      protected $canaddrandom = null;
  64  
  65      /** @var bool tracks whether tags have been loaded */
  66      protected $hasloadedtags = false;
  67  
  68      /**
  69       * @var \stdClass[] the tags for slots. Indexed by slot id.
  70       */
  71      protected $slottags = array();
  72  
  73      /**
  74       * Create an instance of this class representing an empty quiz.
  75       * @return structure
  76       */
  77      public static function create() {
  78          return new self();
  79      }
  80  
  81      /**
  82       * Create an instance of this class representing the structure of a given quiz.
  83       * @param \quiz $quizobj the quiz.
  84       * @return structure
  85       */
  86      public static function create_for_quiz($quizobj) {
  87          $structure = self::create();
  88          $structure->quizobj = $quizobj;
  89          $structure->populate_structure($quizobj->get_quiz());
  90          return $structure;
  91      }
  92  
  93      /**
  94       * Whether there are any questions in the quiz.
  95       * @return bool true if there is at least one question in the quiz.
  96       */
  97      public function has_questions() {
  98          return !empty($this->questions);
  99      }
 100  
 101      /**
 102       * Get the number of questions in the quiz.
 103       * @return int the number of questions in the quiz.
 104       */
 105      public function get_question_count() {
 106          return count($this->questions);
 107      }
 108  
 109      /**
 110       * Get the information about the question with this id.
 111       * @param int $questionid The question id.
 112       * @return \stdClass the data from the questions table, augmented with
 113       * question_category.contextid, and the quiz_slots data for the question in this quiz.
 114       */
 115      public function get_question_by_id($questionid) {
 116          return $this->questions[$questionid];
 117      }
 118  
 119      /**
 120       * Get the information about the question in a given slot.
 121       * @param int $slotnumber the index of the slot in question.
 122       * @return \stdClass the data from the questions table, augmented with
 123       * question_category.contextid, and the quiz_slots data for the question in this quiz.
 124       */
 125      public function get_question_in_slot($slotnumber) {
 126          return $this->questions[$this->slotsinorder[$slotnumber]->questionid];
 127      }
 128  
 129      /**
 130       * Get the displayed question number (or 'i') for a given slot.
 131       * @param int $slotnumber the index of the slot in question.
 132       * @return string the question number ot display for this slot.
 133       */
 134      public function get_displayed_number_for_slot($slotnumber) {
 135          return $this->slotsinorder[$slotnumber]->displayednumber;
 136      }
 137  
 138      /**
 139       * Get the page a given slot is on.
 140       * @param int $slotnumber the index of the slot in question.
 141       * @return int the page number of the page that slot is on.
 142       */
 143      public function get_page_number_for_slot($slotnumber) {
 144          return $this->slotsinorder[$slotnumber]->page;
 145      }
 146  
 147      /**
 148       * Get the slot id of a given slot slot.
 149       * @param int $slotnumber the index of the slot in question.
 150       * @return int the page number of the page that slot is on.
 151       */
 152      public function get_slot_id_for_slot($slotnumber) {
 153          return $this->slotsinorder[$slotnumber]->id;
 154      }
 155  
 156      /**
 157       * Get the question type in a given slot.
 158       * @param int $slotnumber the index of the slot in question.
 159       * @return string the question type (e.g. multichoice).
 160       */
 161      public function get_question_type_for_slot($slotnumber) {
 162          return $this->questions[$this->slotsinorder[$slotnumber]->questionid]->qtype;
 163      }
 164  
 165      /**
 166       * Whether it would be possible, given the question types, etc. for the
 167       * question in the given slot to require that the previous question had been
 168       * answered before this one is displayed.
 169       * @param int $slotnumber the index of the slot in question.
 170       * @return bool can this question require the previous one.
 171       */
 172      public function can_question_depend_on_previous_slot($slotnumber) {
 173          return $slotnumber > 1 && $this->can_finish_during_the_attempt($slotnumber - 1);
 174      }
 175  
 176      /**
 177       * Whether it is possible for another question to depend on this one finishing.
 178       * Note that the answer is not exact, because of random questions, and sometimes
 179       * questions cannot be depended upon because of quiz options.
 180       * @param int $slotnumber the index of the slot in question.
 181       * @return bool can this question finish naturally during the attempt?
 182       */
 183      public function can_finish_during_the_attempt($slotnumber) {
 184          if ($this->quizobj->get_navigation_method() == QUIZ_NAVMETHOD_SEQ) {
 185              return false;
 186          }
 187  
 188          if ($this->slotsinorder[$slotnumber]->section->shufflequestions) {
 189              return false;
 190          }
 191  
 192          if (in_array($this->get_question_type_for_slot($slotnumber), array('random', 'missingtype'))) {
 193              return \question_engine::can_questions_finish_during_the_attempt(
 194                      $this->quizobj->get_quiz()->preferredbehaviour);
 195          }
 196  
 197          if (isset($this->slotsinorder[$slotnumber]->canfinish)) {
 198              return $this->slotsinorder[$slotnumber]->canfinish;
 199          }
 200  
 201          try {
 202              $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $this->quizobj->get_context());
 203              $tempslot = $quba->add_question(\question_bank::load_question(
 204                      $this->slotsinorder[$slotnumber]->questionid));
 205              $quba->set_preferred_behaviour($this->quizobj->get_quiz()->preferredbehaviour);
 206              $quba->start_all_questions();
 207  
 208              $this->slotsinorder[$slotnumber]->canfinish = $quba->can_question_finish_during_attempt($tempslot);
 209              return $this->slotsinorder[$slotnumber]->canfinish;
 210          } catch (\Exception $e) {
 211              // If the question fails to start, this should not block editing.
 212              return false;
 213          }
 214      }
 215  
 216      /**
 217       * Whether it would be possible, given the question types, etc. for the
 218       * question in the given slot to require that the previous question had been
 219       * answered before this one is displayed.
 220       * @param int $slotnumber the index of the slot in question.
 221       * @return bool can this question require the previous one.
 222       */
 223      public function is_question_dependent_on_previous_slot($slotnumber) {
 224          return $this->slotsinorder[$slotnumber]->requireprevious;
 225      }
 226  
 227      /**
 228       * Is a particular question in this attempt a real question, or something like a description.
 229       * @param int $slotnumber the index of the slot in question.
 230       * @return bool whether that question is a real question.
 231       */
 232      public function is_real_question($slotnumber) {
 233          return $this->get_question_in_slot($slotnumber)->length != 0;
 234      }
 235  
 236      /**
 237       * Get the course id that the quiz belongs to.
 238       * @return int the course.id for the quiz.
 239       */
 240      public function get_courseid() {
 241          return $this->quizobj->get_courseid();
 242      }
 243  
 244      /**
 245       * Get the course module id of the quiz.
 246       * @return int the course_modules.id for the quiz.
 247       */
 248      public function get_cmid() {
 249          return $this->quizobj->get_cmid();
 250      }
 251  
 252      /**
 253       * Get id of the quiz.
 254       * @return int the quiz.id for the quiz.
 255       */
 256      public function get_quizid() {
 257          return $this->quizobj->get_quizid();
 258      }
 259  
 260      /**
 261       * Get the quiz object.
 262       * @return \stdClass the quiz settings row from the database.
 263       */
 264      public function get_quiz() {
 265          return $this->quizobj->get_quiz();
 266      }
 267  
 268      /**
 269       * Quizzes can only be repaginated if they have not been attempted, the
 270       * questions are not shuffled, and there are two or more questions.
 271       * @return bool whether this quiz can be repaginated.
 272       */
 273      public function can_be_repaginated() {
 274          return $this->can_be_edited() && $this->get_question_count() >= 2;
 275      }
 276  
 277      /**
 278       * Quizzes can only be edited if they have not been attempted.
 279       * @return bool whether the quiz can be edited.
 280       */
 281      public function can_be_edited() {
 282          if ($this->canbeedited === null) {
 283              $this->canbeedited = !quiz_has_attempts($this->quizobj->get_quizid());
 284          }
 285          return $this->canbeedited;
 286      }
 287  
 288      /**
 289       * This quiz can only be edited if they have not been attempted.
 290       * Throw an exception if this is not the case.
 291       */
 292      public function check_can_be_edited() {
 293          if (!$this->can_be_edited()) {
 294              $reportlink = quiz_attempt_summary_link_to_reports($this->get_quiz(),
 295                      $this->quizobj->get_cm(), $this->quizobj->get_context());
 296              throw new \moodle_exception('cannoteditafterattempts', 'quiz',
 297                      new \moodle_url('/mod/quiz/edit.php', array('cmid' => $this->get_cmid())), $reportlink);
 298          }
 299      }
 300  
 301      /**
 302       * How many questions are allowed per page in the quiz.
 303       * This setting controls how frequently extra page-breaks should be inserted
 304       * automatically when questions are added to the quiz.
 305       * @return int the number of questions that should be on each page of the
 306       * quiz by default.
 307       */
 308      public function get_questions_per_page() {
 309          return $this->quizobj->get_quiz()->questionsperpage;
 310      }
 311  
 312      /**
 313       * Get quiz slots.
 314       * @return \stdClass[] the slots in this quiz.
 315       */
 316      public function get_slots() {
 317          return array_column($this->slotsinorder, null, 'id');
 318      }
 319  
 320      /**
 321       * Is this slot the first one on its page?
 322       * @param int $slotnumber the index of the slot in question.
 323       * @return bool whether this slot the first one on its page.
 324       */
 325      public function is_first_slot_on_page($slotnumber) {
 326          if ($slotnumber == 1) {
 327              return true;
 328          }
 329          return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber - 1]->page;
 330      }
 331  
 332      /**
 333       * Is this slot the last one on its page?
 334       * @param int $slotnumber the index of the slot in question.
 335       * @return bool whether this slot the last one on its page.
 336       */
 337      public function is_last_slot_on_page($slotnumber) {
 338          if (!isset($this->slotsinorder[$slotnumber + 1])) {
 339              return true;
 340          }
 341          return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber + 1]->page;
 342      }
 343  
 344      /**
 345       * Is this slot the last one in its section?
 346       * @param int $slotnumber the index of the slot in question.
 347       * @return bool whether this slot the last one on its section.
 348       */
 349      public function is_last_slot_in_section($slotnumber) {
 350          return $slotnumber == $this->slotsinorder[$slotnumber]->section->lastslot;
 351      }
 352  
 353      /**
 354       * Is this slot the only one in its section?
 355       * @param int $slotnumber the index of the slot in question.
 356       * @return bool whether this slot the only one on its section.
 357       */
 358      public function is_only_slot_in_section($slotnumber) {
 359          return $this->slotsinorder[$slotnumber]->section->firstslot ==
 360                  $this->slotsinorder[$slotnumber]->section->lastslot;
 361      }
 362  
 363      /**
 364       * Is this slot the last one in the quiz?
 365       * @param int $slotnumber the index of the slot in question.
 366       * @return bool whether this slot the last one in the quiz.
 367       */
 368      public function is_last_slot_in_quiz($slotnumber) {
 369          end($this->slotsinorder);
 370          return $slotnumber == key($this->slotsinorder);
 371      }
 372  
 373      /**
 374       * Is this the first section in the quiz?
 375       * @param \stdClass $section the quiz_sections row.
 376       * @return bool whether this is first section in the quiz.
 377       */
 378      public function is_first_section($section) {
 379          return $section->firstslot == 1;
 380      }
 381  
 382      /**
 383       * Is this the last section in the quiz?
 384       * @param \stdClass $section the quiz_sections row.
 385       * @return bool whether this is first section in the quiz.
 386       */
 387      public function is_last_section($section) {
 388          return $section->id == end($this->sections)->id;
 389      }
 390  
 391      /**
 392       * Does this section only contain one slot?
 393       * @param \stdClass $section the quiz_sections row.
 394       * @return bool whether this section contains only one slot.
 395       */
 396      public function is_only_one_slot_in_section($section) {
 397          return $section->firstslot == $section->lastslot;
 398      }
 399  
 400      /**
 401       * Get the final slot in the quiz.
 402       * @return \stdClass the quiz_slots for for the final slot in the quiz.
 403       */
 404      public function get_last_slot() {
 405          return end($this->slotsinorder);
 406      }
 407  
 408      /**
 409       * Get a slot by it's id. Throws an exception if it is missing.
 410       * @param int $slotid the slot id.
 411       * @return \stdClass the requested quiz_slots row.
 412       * @throws \coding_exception
 413       */
 414      public function get_slot_by_id($slotid) {
 415          foreach ($this->slotsinorder as $slot) {
 416              if ($slot->id == $slotid) {
 417                  return $slot;
 418              }
 419          }
 420  
 421          throw new \coding_exception('The \'slotid\' could not be found.');
 422      }
 423  
 424      /**
 425       * Get a slot by it's slot number. Throws an exception if it is missing.
 426       *
 427       * @param int $slotnumber The slot number
 428       * @return \stdClass
 429       * @throws \coding_exception
 430       */
 431      public function get_slot_by_number($slotnumber) {
 432          if (!array_key_exists($slotnumber, $this->slotsinorder)) {
 433              throw new \coding_exception('The \'slotnumber\' could not be found.');
 434          }
 435          return $this->slotsinorder[$slotnumber];
 436      }
 437  
 438      /**
 439       * Check whether adding a section heading is possible
 440       * @param int $pagenumber the number of the page.
 441       * @return boolean
 442       */
 443      public function can_add_section_heading($pagenumber) {
 444          // There is a default section heading on this page,
 445          // do not show adding new section heading in the Add menu.
 446          if ($pagenumber == 1) {
 447              return false;
 448          }
 449          // Get an array of firstslots.
 450          $firstslots = array();
 451          foreach ($this->sections as $section) {
 452              $firstslots[] = $section->firstslot;
 453          }
 454          foreach ($this->slotsinorder as $slot) {
 455              if ($slot->page == $pagenumber) {
 456                  if (in_array($slot->slot, $firstslots)) {
 457                      return false;
 458                  }
 459              }
 460          }
 461          // Do not show the adding section heading on the last add menu.
 462          if ($pagenumber == 0) {
 463              return false;
 464          }
 465          return true;
 466      }
 467  
 468      /**
 469       * Get all the slots in a section of the quiz.
 470       * @param int $sectionid the section id.
 471       * @return int[] slot numbers.
 472       */
 473      public function get_slots_in_section($sectionid) {
 474          $slots = array();
 475          foreach ($this->slotsinorder as $slot) {
 476              if ($slot->section->id == $sectionid) {
 477                  $slots[] = $slot->slot;
 478              }
 479          }
 480          return $slots;
 481      }
 482  
 483      /**
 484       * Get all the sections of the quiz.
 485       * @return \stdClass[] the sections in this quiz.
 486       */
 487      public function get_sections() {
 488          return $this->sections;
 489      }
 490  
 491      /**
 492       * Get a particular section by id.
 493       * @return \stdClass the section.
 494       */
 495      public function get_section_by_id($sectionid) {
 496          return $this->sections[$sectionid];
 497      }
 498  
 499      /**
 500       * Get the number of questions in the quiz.
 501       * @return int the number of questions in the quiz.
 502       */
 503      public function get_section_count() {
 504          return count($this->sections);
 505      }
 506  
 507      /**
 508       * Get the overall quiz grade formatted for display.
 509       * @return string the maximum grade for this quiz.
 510       */
 511      public function formatted_quiz_grade() {
 512          return quiz_format_grade($this->get_quiz(), $this->get_quiz()->grade);
 513      }
 514  
 515      /**
 516       * Get the maximum mark for a question, formatted for display.
 517       * @param int $slotnumber the index of the slot in question.
 518       * @return string the maximum mark for the question in this slot.
 519       */
 520      public function formatted_question_grade($slotnumber) {
 521          return quiz_format_question_grade($this->get_quiz(), $this->slotsinorder[$slotnumber]->maxmark);
 522      }
 523  
 524      /**
 525       * Get the number of decimal places for displyaing overall quiz grades or marks.
 526       * @return int the number of decimal places.
 527       */
 528      public function get_decimal_places_for_grades() {
 529          return $this->get_quiz()->decimalpoints;
 530      }
 531  
 532      /**
 533       * Get the number of decimal places for displyaing question marks.
 534       * @return int the number of decimal places.
 535       */
 536      public function get_decimal_places_for_question_marks() {
 537          return quiz_get_grade_format($this->get_quiz());
 538      }
 539  
 540      /**
 541       * Get any warnings to show at the top of the edit page.
 542       * @return string[] array of strings.
 543       */
 544      public function get_edit_page_warnings() {
 545          $warnings = array();
 546  
 547          if (quiz_has_attempts($this->quizobj->get_quizid())) {
 548              $reviewlink = quiz_attempt_summary_link_to_reports($this->quizobj->get_quiz(),
 549                      $this->quizobj->get_cm(), $this->quizobj->get_context());
 550              $warnings[] = get_string('cannoteditafterattempts', 'quiz', $reviewlink);
 551          }
 552  
 553          return $warnings;
 554      }
 555  
 556      /**
 557       * Get the date information about the current state of the quiz.
 558       * @return string[] array of two strings. First a short summary, then a longer
 559       * explanation of the current state, e.g. for a tool-tip.
 560       */
 561      public function get_dates_summary() {
 562          $timenow = time();
 563          $quiz = $this->quizobj->get_quiz();
 564  
 565          // Exact open and close dates for the tool-tip.
 566          $dates = array();
 567          if ($quiz->timeopen > 0) {
 568              if ($timenow > $quiz->timeopen) {
 569                  $dates[] = get_string('quizopenedon', 'quiz', userdate($quiz->timeopen));
 570              } else {
 571                  $dates[] = get_string('quizwillopen', 'quiz', userdate($quiz->timeopen));
 572              }
 573          }
 574          if ($quiz->timeclose > 0) {
 575              if ($timenow > $quiz->timeclose) {
 576                  $dates[] = get_string('quizclosed', 'quiz', userdate($quiz->timeclose));
 577              } else {
 578                  $dates[] = get_string('quizcloseson', 'quiz', userdate($quiz->timeclose));
 579              }
 580          }
 581          if (empty($dates)) {
 582              $dates[] = get_string('alwaysavailable', 'quiz');
 583          }
 584          $explanation = implode(', ', $dates);
 585  
 586          // Brief summary on the page.
 587          if ($timenow < $quiz->timeopen) {
 588              $currentstatus = get_string('quizisclosedwillopen', 'quiz',
 589                      userdate($quiz->timeopen, get_string('strftimedatetimeshort', 'langconfig')));
 590          } else if ($quiz->timeclose && $timenow <= $quiz->timeclose) {
 591              $currentstatus = get_string('quizisopenwillclose', 'quiz',
 592                      userdate($quiz->timeclose, get_string('strftimedatetimeshort', 'langconfig')));
 593          } else if ($quiz->timeclose && $timenow > $quiz->timeclose) {
 594              $currentstatus = get_string('quizisclosed', 'quiz');
 595          } else {
 596              $currentstatus = get_string('quizisopen', 'quiz');
 597          }
 598  
 599          return array($currentstatus, $explanation);
 600      }
 601  
 602      /**
 603       * Set up this class with the structure for a given quiz.
 604       * @param \stdClass $quiz the quiz settings.
 605       */
 606      public function populate_structure($quiz) {
 607          global $DB;
 608  
 609          $slots = $DB->get_records_sql("
 610                  SELECT slot.id AS slotid, slot.slot, slot.questionid, slot.page, slot.maxmark,
 611                          slot.requireprevious, q.*, qc.contextid
 612                    FROM {quiz_slots} slot
 613                    LEFT JOIN {question} q ON q.id = slot.questionid
 614                    LEFT JOIN {question_categories} qc ON qc.id = q.category
 615                   WHERE slot.quizid = ?
 616                ORDER BY slot.slot", array($quiz->id));
 617  
 618          $slots = $this->populate_missing_questions($slots);
 619  
 620          $this->questions = array();
 621          $this->slotsinorder = array();
 622          foreach ($slots as $slotdata) {
 623              $this->questions[$slotdata->questionid] = $slotdata;
 624  
 625              $slot = new \stdClass();
 626              $slot->id = $slotdata->slotid;
 627              $slot->slot = $slotdata->slot;
 628              $slot->quizid = $quiz->id;
 629              $slot->page = $slotdata->page;
 630              $slot->questionid = $slotdata->questionid;
 631              $slot->maxmark = $slotdata->maxmark;
 632              $slot->requireprevious = $slotdata->requireprevious;
 633  
 634              $this->slotsinorder[$slot->slot] = $slot;
 635          }
 636  
 637          // Get quiz sections in ascending order of the firstslot.
 638          $this->sections = $DB->get_records('quiz_sections', array('quizid' => $quiz->id), 'firstslot ASC');
 639          $this->populate_slots_with_sections();
 640          $this->populate_question_numbers();
 641      }
 642  
 643      /**
 644       * Used by populate. Make up fake data for any missing questions.
 645       * @param \stdClass[] $slots the data about the slots and questions in the quiz.
 646       * @return \stdClass[] updated $slots array.
 647       */
 648      protected function populate_missing_questions($slots) {
 649          // Address missing question types.
 650          foreach ($slots as $slot) {
 651              if ($slot->qtype === null) {
 652                  // If the questiontype is missing change the question type.
 653                  $slot->id = $slot->questionid;
 654                  $slot->category = 0;
 655                  $slot->qtype = 'missingtype';
 656                  $slot->name = get_string('missingquestion', 'quiz');
 657                  $slot->slot = $slot->slot;
 658                  $slot->maxmark = 0;
 659                  $slot->requireprevious = 0;
 660                  $slot->questiontext = ' ';
 661                  $slot->questiontextformat = FORMAT_HTML;
 662                  $slot->length = 1;
 663  
 664              } else if (!\question_bank::qtype_exists($slot->qtype)) {
 665                  $slot->qtype = 'missingtype';
 666              }
 667          }
 668  
 669          return $slots;
 670      }
 671  
 672      /**
 673       * Fill in the section ids for each slot.
 674       */
 675      public function populate_slots_with_sections() {
 676          $sections = array_values($this->sections);
 677          foreach ($sections as $i => $section) {
 678              if (isset($sections[$i + 1])) {
 679                  $section->lastslot = $sections[$i + 1]->firstslot - 1;
 680              } else {
 681                  $section->lastslot = count($this->slotsinorder);
 682              }
 683              for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
 684                  $this->slotsinorder[$slot]->section = $section;
 685              }
 686          }
 687      }
 688  
 689      /**
 690       * Number the questions.
 691       */
 692      protected function populate_question_numbers() {
 693          $number = 1;
 694          foreach ($this->slotsinorder as $slot) {
 695              if ($this->questions[$slot->questionid]->length == 0) {
 696                  $slot->displayednumber = get_string('infoshort', 'quiz');
 697              } else {
 698                  $slot->displayednumber = $number;
 699                  $number += 1;
 700              }
 701          }
 702      }
 703  
 704      /**
 705       * Move a slot from its current location to a new location.
 706       *
 707       * After callig this method, this class will be in an invalid state, and
 708       * should be discarded if you want to manipulate the structure further.
 709       *
 710       * @param int $idmove id of slot to be moved
 711       * @param int $idmoveafter id of slot to come before slot being moved
 712       * @param int $page new page number of slot being moved
 713       * @param bool $insection if the question is moving to a place where a new
 714       *      section starts, include it in that section.
 715       * @return void
 716       */
 717      public function move_slot($idmove, $idmoveafter, $page) {
 718          global $DB;
 719  
 720          $this->check_can_be_edited();
 721  
 722          $movingslot = $this->get_slot_by_id($idmove);
 723          if (empty($movingslot)) {
 724              throw new \moodle_exception('Bad slot ID ' . $idmove);
 725          }
 726          $movingslotnumber = (int) $movingslot->slot;
 727  
 728          // Empty target slot means move slot to first.
 729          if (empty($idmoveafter)) {
 730              $moveafterslotnumber = 0;
 731          } else {
 732              $moveafterslotnumber = (int) $this->get_slot_by_id($idmoveafter)->slot;
 733          }
 734  
 735          // If the action came in as moving a slot to itself, normalise this to
 736          // moving the slot to after the previous slot.
 737          if ($moveafterslotnumber == $movingslotnumber) {
 738              $moveafterslotnumber = $moveafterslotnumber - 1;
 739          }
 740  
 741          $followingslotnumber = $moveafterslotnumber + 1;
 742          // Prevent checking against non-existance slot when already at the last slot.
 743          if ($followingslotnumber == $movingslotnumber && !$this->is_last_slot_in_quiz($followingslotnumber)) {
 744              $followingslotnumber += 1;
 745          }
 746  
 747          // Check the target page number is OK.
 748          if ($page == 0 || $page === '') {
 749              $page = 1;
 750          }
 751          if (($moveafterslotnumber > 0 && $page < $this->get_page_number_for_slot($moveafterslotnumber)) ||
 752                  $page < 1) {
 753              throw new \coding_exception('The target page number is too small.');
 754          } else if (!$this->is_last_slot_in_quiz($moveafterslotnumber) &&
 755                  $page > $this->get_page_number_for_slot($followingslotnumber)) {
 756              throw new \coding_exception('The target page number is too large.');
 757          }
 758  
 759          // Work out how things are being moved.
 760          $slotreorder = array();
 761          if ($moveafterslotnumber > $movingslotnumber) {
 762              // Moving down.
 763              $slotreorder[$movingslotnumber] = $moveafterslotnumber;
 764              for ($i = $movingslotnumber; $i < $moveafterslotnumber; $i++) {
 765                  $slotreorder[$i + 1] = $i;
 766              }
 767  
 768              $headingmoveafter = $movingslotnumber;
 769              if ($this->is_last_slot_in_quiz($moveafterslotnumber) ||
 770                      $page == $this->get_page_number_for_slot($moveafterslotnumber + 1)) {
 771                  // We are moving to the start of a section, so that heading needs
 772                  // to be included in the ones that move up.
 773                  $headingmovebefore = $moveafterslotnumber + 1;
 774              } else {
 775                  $headingmovebefore = $moveafterslotnumber;
 776              }
 777              $headingmovedirection = -1;
 778  
 779          } else if ($moveafterslotnumber < $movingslotnumber - 1) {
 780              // Moving up.
 781              $slotreorder[$movingslotnumber] = $moveafterslotnumber + 1;
 782              for ($i = $moveafterslotnumber + 1; $i < $movingslotnumber; $i++) {
 783                  $slotreorder[$i] = $i + 1;
 784              }
 785  
 786              if ($page == $this->get_page_number_for_slot($moveafterslotnumber + 1)) {
 787                  // Moving to the start of a section, don't move that section.
 788                  $headingmoveafter = $moveafterslotnumber + 1;
 789              } else {
 790                  // Moving tot the end of the previous section, so move the heading down too.
 791                  $headingmoveafter = $moveafterslotnumber;
 792              }
 793              $headingmovebefore = $movingslotnumber + 1;
 794              $headingmovedirection = 1;
 795          } else {
 796              // Staying in the same place, but possibly changing page/section.
 797              if ($page > $movingslot->page) {
 798                  $headingmoveafter = $movingslotnumber;
 799                  $headingmovebefore = $movingslotnumber + 2;
 800                  $headingmovedirection = -1;
 801              } else if ($page < $movingslot->page) {
 802                  $headingmoveafter = $movingslotnumber - 1;
 803                  $headingmovebefore = $movingslotnumber + 1;
 804                  $headingmovedirection = 1;
 805              } else {
 806                  return; // Nothing to do.
 807              }
 808          }
 809  
 810          if ($this->is_only_slot_in_section($movingslotnumber)) {
 811              throw new \coding_exception('You cannot remove the last slot in a section.');
 812          }
 813  
 814          $trans = $DB->start_delegated_transaction();
 815  
 816          // Slot has moved record new order.
 817          if ($slotreorder) {
 818              update_field_with_unique_index('quiz_slots', 'slot', $slotreorder,
 819                      array('quizid' => $this->get_quizid()));
 820          }
 821  
 822          // Page has changed. Record it.
 823          if ($movingslot->page != $page) {
 824              $DB->set_field('quiz_slots', 'page', $page,
 825                      array('id' => $movingslot->id));
 826          }
 827  
 828          // Update section fist slots.
 829          quiz_update_section_firstslots($this->get_quizid(), $headingmovedirection,
 830                  $headingmoveafter, $headingmovebefore);
 831  
 832          // If any pages are now empty, remove them.
 833          $emptypages = $DB->get_fieldset_sql("
 834                  SELECT DISTINCT page - 1
 835                    FROM {quiz_slots} slot
 836                   WHERE quizid = ?
 837                     AND page > 1
 838                     AND NOT EXISTS (SELECT 1 FROM {quiz_slots} WHERE quizid = ? AND page = slot.page - 1)
 839                ORDER BY page - 1 DESC
 840                  ", array($this->get_quizid(), $this->get_quizid()));
 841  
 842          foreach ($emptypages as $page) {
 843              $DB->execute("
 844                      UPDATE {quiz_slots}
 845                         SET page = page - 1
 846                       WHERE quizid = ?
 847                         AND page > ?
 848                      ", array($this->get_quizid(), $page));
 849          }
 850  
 851          $trans->allow_commit();
 852      }
 853  
 854      /**
 855       * Refresh page numbering of quiz slots.
 856       * @param \stdClass[] $slots (optional) array of slot objects.
 857       * @return \stdClass[] array of slot objects.
 858       */
 859      public function refresh_page_numbers($slots = array()) {
 860          global $DB;
 861          // Get slots ordered by page then slot.
 862          if (!count($slots)) {
 863              $slots = $DB->get_records('quiz_slots', array('quizid' => $this->get_quizid()), 'slot, page');
 864          }
 865  
 866          // Loop slots. Start Page number at 1 and increment as required.
 867          $pagenumbers = array('new' => 0, 'old' => 0);
 868  
 869          foreach ($slots as $slot) {
 870              if ($slot->page !== $pagenumbers['old']) {
 871                  $pagenumbers['old'] = $slot->page;
 872                  ++$pagenumbers['new'];
 873              }
 874  
 875              if ($pagenumbers['new'] == $slot->page) {
 876                  continue;
 877              }
 878              $slot->page = $pagenumbers['new'];
 879          }
 880  
 881          return $slots;
 882      }
 883  
 884      /**
 885       * Refresh page numbering of quiz slots and save to the database.
 886       * @param \stdClass $quiz the quiz object.
 887       * @return \stdClass[] array of slot objects.
 888       */
 889      public function refresh_page_numbers_and_update_db() {
 890          global $DB;
 891          $this->check_can_be_edited();
 892  
 893          $slots = $this->refresh_page_numbers();
 894  
 895          // Record new page order.
 896          foreach ($slots as $slot) {
 897              $DB->set_field('quiz_slots', 'page', $slot->page,
 898                      array('id' => $slot->id));
 899          }
 900  
 901          return $slots;
 902      }
 903  
 904      /**
 905       * Remove a slot from a quiz
 906       *
 907       * @param int $slotnumber The number of the slot to be deleted.
 908       * @throws \coding_exception
 909       */
 910      public function remove_slot($slotnumber) {
 911          global $DB;
 912  
 913          $this->check_can_be_edited();
 914  
 915          if ($this->is_only_slot_in_section($slotnumber) && $this->get_section_count() > 1) {
 916              throw new \coding_exception('You cannot remove the last slot in a section.');
 917          }
 918  
 919          $slot = $DB->get_record('quiz_slots', array('quizid' => $this->get_quizid(), 'slot' => $slotnumber));
 920          if (!$slot) {
 921              return;
 922          }
 923          $maxslot = $DB->get_field_sql('SELECT MAX(slot) FROM {quiz_slots} WHERE quizid = ?', array($this->get_quizid()));
 924  
 925          $trans = $DB->start_delegated_transaction();
 926          $DB->delete_records('quiz_slot_tags', array('slotid' => $slot->id));
 927          $DB->delete_records('quiz_slots', array('id' => $slot->id));
 928          for ($i = $slot->slot + 1; $i <= $maxslot; $i++) {
 929              $DB->set_field('quiz_slots', 'slot', $i - 1,
 930                      array('quizid' => $this->get_quizid(), 'slot' => $i));
 931              $this->slotsinorder[$i]->slot = $i - 1;
 932              $this->slotsinorder[$i - 1] = $this->slotsinorder[$i];
 933              unset($this->slotsinorder[$i]);
 934          }
 935  
 936          $qtype = $DB->get_field('question', 'qtype', array('id' => $slot->questionid));
 937          if ($qtype === 'random') {
 938              // This function automatically checks if the question is in use, and won't delete if it is.
 939              question_delete_question($slot->questionid);
 940          }
 941  
 942          quiz_update_section_firstslots($this->get_quizid(), -1, $slotnumber);
 943          foreach ($this->sections as $key => $section) {
 944              if ($section->firstslot > $slotnumber) {
 945                  $this->sections[$key]->firstslot--;
 946              }
 947          }
 948          $this->populate_slots_with_sections();
 949          $this->populate_question_numbers();
 950          unset($this->questions[$slot->questionid]);
 951  
 952          $this->refresh_page_numbers_and_update_db();
 953  
 954          $trans->allow_commit();
 955      }
 956  
 957      /**
 958       * Change the max mark for a slot.
 959       *
 960       * Saves changes to the question grades in the quiz_slots table and any
 961       * corresponding question_attempts.
 962       * It does not update 'sumgrades' in the quiz table.
 963       *
 964       * @param \stdClass $slot row from the quiz_slots table.
 965       * @param float $maxmark the new maxmark.
 966       * @return bool true if the new grade is different from the old one.
 967       */
 968      public function update_slot_maxmark($slot, $maxmark) {
 969          global $DB;
 970  
 971          if (abs($maxmark - $slot->maxmark) < 1e-7) {
 972              // Grade has not changed. Nothing to do.
 973              return false;
 974          }
 975  
 976          $trans = $DB->start_delegated_transaction();
 977          $slot->maxmark = $maxmark;
 978          $DB->update_record('quiz_slots', $slot);
 979          \question_engine::set_max_mark_in_attempts(new \qubaids_for_quiz($slot->quizid),
 980                  $slot->slot, $maxmark);
 981          $trans->allow_commit();
 982  
 983          return true;
 984      }
 985  
 986      /**
 987       * Set whether the question in a particular slot requires the previous one.
 988       * @param int $slotid id of slot.
 989       * @param bool $requireprevious if true, set this question to require the previous one.
 990       */
 991      public function update_question_dependency($slotid, $requireprevious) {
 992          global $DB;
 993          $DB->set_field('quiz_slots', 'requireprevious', $requireprevious, array('id' => $slotid));
 994      }
 995  
 996      /**
 997       * Add/Remove a pagebreak.
 998       *
 999       * Saves changes to the slot page relationship in the quiz_slots table and reorders the paging
1000       * for subsequent slots.
1001       *
1002       * @param int $slotid id of slot.
1003       * @param int $type repaginate::LINK or repaginate::UNLINK.
1004       * @return \stdClass[] array of slot objects.
1005       */
1006      public function update_page_break($slotid, $type) {
1007          global $DB;
1008  
1009          $this->check_can_be_edited();
1010  
1011          $quizslots = $DB->get_records('quiz_slots', array('quizid' => $this->get_quizid()), 'slot');
1012          $repaginate = new \mod_quiz\repaginate($this->get_quizid(), $quizslots);
1013          $repaginate->repaginate_slots($quizslots[$slotid]->slot, $type);
1014          $slots = $this->refresh_page_numbers_and_update_db();
1015  
1016          return $slots;
1017      }
1018  
1019      /**
1020       * Add a section heading on a given page and return the sectionid
1021       * @param int $pagenumber the number of the page where the section heading begins.
1022       * @param string|null $heading the heading to add. If not given, a default is used.
1023       */
1024      public function add_section_heading($pagenumber, $heading = null) {
1025          global $DB;
1026          $section = new \stdClass();
1027          if ($heading !== null) {
1028              $section->heading = $heading;
1029          } else {
1030              $section->heading = get_string('newsectionheading', 'quiz');
1031          }
1032          $section->quizid = $this->get_quizid();
1033          $slotsonpage = $DB->get_records('quiz_slots', array('quizid' => $this->get_quizid(), 'page' => $pagenumber), 'slot DESC');
1034          $section->firstslot = end($slotsonpage)->slot;
1035          $section->shufflequestions = 0;
1036          return $DB->insert_record('quiz_sections', $section);
1037      }
1038  
1039      /**
1040       * Change the heading for a section.
1041       * @param int $id the id of the section to change.
1042       * @param string $newheading the new heading for this section.
1043       */
1044      public function set_section_heading($id, $newheading) {
1045          global $DB;
1046          $section = $DB->get_record('quiz_sections', array('id' => $id), '*', MUST_EXIST);
1047          $section->heading = $newheading;
1048          $DB->update_record('quiz_sections', $section);
1049      }
1050  
1051      /**
1052       * Change the shuffle setting for a section.
1053       * @param int $id the id of the section to change.
1054       * @param bool $shuffle whether this section should be shuffled.
1055       */
1056      public function set_section_shuffle($id, $shuffle) {
1057          global $DB;
1058          $section = $DB->get_record('quiz_sections', array('id' => $id), '*', MUST_EXIST);
1059          $section->shufflequestions = $shuffle;
1060          $DB->update_record('quiz_sections', $section);
1061      }
1062  
1063      /**
1064       * Remove the section heading with the given id
1065       * @param int $sectionid the section to remove.
1066       */
1067      public function remove_section_heading($sectionid) {
1068          global $DB;
1069          $section = $DB->get_record('quiz_sections', array('id' => $sectionid), '*', MUST_EXIST);
1070          if ($section->firstslot == 1) {
1071              throw new \coding_exception('Cannot remove the first section in a quiz.');
1072          }
1073          $DB->delete_records('quiz_sections', array('id' => $sectionid));
1074      }
1075  
1076      /**
1077       * Set up this class with the slot tags for each of the slots.
1078       */
1079      protected function populate_slot_tags() {
1080          $slotids = array_column($this->slotsinorder, 'id');
1081          $this->slottags = quiz_retrieve_tags_for_slot_ids($slotids);
1082      }
1083  
1084      /**
1085       * Retrieve the list of slot tags for the given slot id.
1086       *
1087       * @param  int $slotid The id for the slot
1088       * @return \stdClass[] The list of slot tag records
1089       */
1090      public function get_slot_tags_for_slot_id($slotid) {
1091          if (!$this->hasloadedtags) {
1092              // Lazy load the tags just in case they are never required.
1093              $this->populate_slot_tags();
1094              $this->hasloadedtags = true;
1095          }
1096  
1097          return isset($this->slottags[$slotid]) ? $this->slottags[$slotid] : [];
1098      }
1099  
1100      /**
1101       * Whether the current user can add random questions to the quiz or not.
1102       * It is only possible to add a random question if the user has the moodle/question:useall capability
1103       * on at least one of the contexts related to the one where we are currently editing questions.
1104       *
1105       * @return bool
1106       */
1107      public function can_add_random_questions() {
1108          if ($this->canaddrandom === null) {
1109              $quizcontext = $this->quizobj->get_context();
1110              $relatedcontexts = new \question_edit_contexts($quizcontext);
1111              $usablecontexts = $relatedcontexts->having_cap('moodle/question:useall');
1112  
1113              $this->canaddrandom = !empty($usablecontexts);
1114          }
1115  
1116          return $this->canaddrandom;
1117      }
1118  }