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