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