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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body