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 /** 18 * Library of functions used by the quiz module. 19 * 20 * This contains functions that are called from within the quiz module only 21 * Functions that are also called by core Moodle are in {@link lib.php} 22 * This script also loads the code in {@link questionlib.php} which holds 23 * the module-indpendent code for handling questions and which in turn 24 * initialises all the questiontype classes. 25 * 26 * @package mod_quiz 27 * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} 28 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 29 */ 30 31 defined('MOODLE_INTERNAL') || die(); 32 33 require_once($CFG->dirroot . '/mod/quiz/lib.php'); 34 require_once($CFG->libdir . '/completionlib.php'); 35 require_once($CFG->libdir . '/filelib.php'); 36 require_once($CFG->libdir . '/questionlib.php'); 37 38 use mod_quiz\access_manager; 39 use mod_quiz\event\attempt_submitted; 40 use mod_quiz\grade_calculator; 41 use mod_quiz\question\bank\qbank_helper; 42 use mod_quiz\question\display_options; 43 use mod_quiz\quiz_attempt; 44 use mod_quiz\quiz_settings; 45 use qbank_previewquestion\question_preview_options; 46 47 /** 48 * @var int We show the countdown timer if there is less than this amount of time left before the 49 * the quiz close date. (1 hour) 50 */ 51 define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600'); 52 53 /** 54 * @var int If there are fewer than this many seconds left when the student submits 55 * a page of the quiz, then do not take them to the next page of the quiz. Instead 56 * close the quiz immediately. 57 */ 58 define('QUIZ_MIN_TIME_TO_CONTINUE', '2'); 59 60 /** 61 * @var int We show no image when user selects No image from dropdown menu in quiz settings. 62 */ 63 define('QUIZ_SHOWIMAGE_NONE', 0); 64 65 /** 66 * @var int We show small image when user selects small image from dropdown menu in quiz settings. 67 */ 68 define('QUIZ_SHOWIMAGE_SMALL', 1); 69 70 /** 71 * @var int We show Large image when user selects Large image from dropdown menu in quiz settings. 72 */ 73 define('QUIZ_SHOWIMAGE_LARGE', 2); 74 75 76 // Functions related to attempts /////////////////////////////////////////////// 77 78 /** 79 * Creates an object to represent a new attempt at a quiz 80 * 81 * Creates an attempt object to represent an attempt at the quiz by the current 82 * user starting at the current time. The ->id field is not set. The object is 83 * NOT written to the database. 84 * 85 * @param quiz_settings $quizobj the quiz object to create an attempt for. 86 * @param int $attemptnumber the sequence number for the attempt. 87 * @param stdClass|false $lastattempt the previous attempt by this user, if any. Only needed 88 * if $attemptnumber > 1 and $quiz->attemptonlast is true. 89 * @param int $timenow the time the attempt was started at. 90 * @param bool $ispreview whether this new attempt is a preview. 91 * @param int|null $userid the id of the user attempting this quiz. 92 * 93 * @return stdClass the newly created attempt object. 94 */ 95 function quiz_create_attempt(quiz_settings $quizobj, $attemptnumber, $lastattempt, $timenow, $ispreview = false, $userid = null) { 96 global $USER; 97 98 if ($userid === null) { 99 $userid = $USER->id; 100 } 101 102 $quiz = $quizobj->get_quiz(); 103 if ($quiz->sumgrades < grade_calculator::ALMOST_ZERO && $quiz->grade > grade_calculator::ALMOST_ZERO) { 104 throw new moodle_exception('cannotstartgradesmismatch', 'quiz', 105 new moodle_url('/mod/quiz/view.php', ['q' => $quiz->id]), 106 ['grade' => quiz_format_grade($quiz, $quiz->grade)]); 107 } 108 109 if ($attemptnumber == 1 || !$quiz->attemptonlast) { 110 // We are not building on last attempt so create a new attempt. 111 $attempt = new stdClass(); 112 $attempt->quiz = $quiz->id; 113 $attempt->userid = $userid; 114 $attempt->preview = 0; 115 $attempt->layout = ''; 116 } else { 117 // Build on last attempt. 118 if (empty($lastattempt)) { 119 throw new \moodle_exception('cannotfindprevattempt', 'quiz'); 120 } 121 $attempt = $lastattempt; 122 } 123 124 $attempt->attempt = $attemptnumber; 125 $attempt->timestart = $timenow; 126 $attempt->timefinish = 0; 127 $attempt->timemodified = $timenow; 128 $attempt->timemodifiedoffline = 0; 129 $attempt->state = quiz_attempt::IN_PROGRESS; 130 $attempt->currentpage = 0; 131 $attempt->sumgrades = null; 132 $attempt->gradednotificationsenttime = null; 133 134 // If this is a preview, mark it as such. 135 if ($ispreview) { 136 $attempt->preview = 1; 137 } 138 139 $timeclose = $quizobj->get_access_manager($timenow)->get_end_time($attempt); 140 if ($timeclose === false || $ispreview) { 141 $attempt->timecheckstate = null; 142 } else { 143 $attempt->timecheckstate = $timeclose; 144 } 145 146 return $attempt; 147 } 148 /** 149 * Start a normal, new, quiz attempt. 150 * 151 * @param quiz_settings $quizobj the quiz object to start an attempt for. 152 * @param question_usage_by_activity $quba 153 * @param stdClass $attempt 154 * @param integer $attemptnumber starting from 1 155 * @param integer $timenow the attempt start time 156 * @param array $questionids slot number => question id. Used for random questions, to force the choice 157 * of a particular actual question. Intended for testing purposes only. 158 * @param array $forcedvariantsbyslot slot number => variant. Used for questions with variants, 159 * to force the choice of a particular variant. Intended for testing 160 * purposes only. 161 * @return stdClass modified attempt object 162 */ 163 function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, 164 $questionids = [], $forcedvariantsbyslot = []) { 165 166 // Usages for this user's previous quiz attempts. 167 $qubaids = new \mod_quiz\question\qubaids_for_users_attempts( 168 $quizobj->get_quizid(), $attempt->userid); 169 170 // Fully load all the questions in this quiz. 171 $quizobj->preload_questions(); 172 $quizobj->load_questions(); 173 174 // First load all the non-random questions. 175 $randomfound = false; 176 $slot = 0; 177 $questions = []; 178 $maxmark = []; 179 $page = []; 180 foreach ($quizobj->get_questions() as $questiondata) { 181 $slot += 1; 182 $maxmark[$slot] = $questiondata->maxmark; 183 $page[$slot] = $questiondata->page; 184 if ($questiondata->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT) { 185 throw new moodle_exception('questiondraftonly', 'mod_quiz', '', $questiondata->name); 186 } 187 if ($questiondata->qtype == 'random') { 188 $randomfound = true; 189 continue; 190 } 191 if (!$quizobj->get_quiz()->shuffleanswers) { 192 $questiondata->options->shuffleanswers = false; 193 } 194 $questions[$slot] = question_bank::make_question($questiondata); 195 } 196 197 // Then find a question to go in place of each random question. 198 if ($randomfound) { 199 $slot = 0; 200 $usedquestionids = []; 201 foreach ($questions as $question) { 202 if ($question->id && isset($usedquestions[$question->id])) { 203 $usedquestionids[$question->id] += 1; 204 } else { 205 $usedquestionids[$question->id] = 1; 206 } 207 } 208 $randomloader = new \core_question\local\bank\random_question_loader($qubaids, $usedquestionids); 209 210 foreach ($quizobj->get_questions() as $questiondata) { 211 $slot += 1; 212 if ($questiondata->qtype != 'random') { 213 continue; 214 } 215 216 $tagids = qbank_helper::get_tag_ids_for_slot($questiondata); 217 218 // Deal with fixed random choices for testing. 219 if (isset($questionids[$quba->next_slot_number()])) { 220 if ($randomloader->is_question_available($questiondata->category, 221 (bool) $questiondata->questiontext, $questionids[$quba->next_slot_number()], $tagids)) { 222 $questions[$slot] = question_bank::load_question( 223 $questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers); 224 continue; 225 } else { 226 throw new coding_exception('Forced question id not available.'); 227 } 228 } 229 230 // Normal case, pick one at random. 231 $questionid = $randomloader->get_next_question_id($questiondata->category, 232 $questiondata->randomrecurse, $tagids); 233 if ($questionid === null) { 234 throw new moodle_exception('notenoughrandomquestions', 'quiz', 235 $quizobj->view_url(), $questiondata); 236 } 237 238 $questions[$slot] = question_bank::load_question($questionid, 239 $quizobj->get_quiz()->shuffleanswers); 240 } 241 } 242 243 // Finally add them all to the usage. 244 ksort($questions); 245 foreach ($questions as $slot => $question) { 246 $newslot = $quba->add_question($question, $maxmark[$slot]); 247 if ($newslot != $slot) { 248 throw new coding_exception('Slot numbers have got confused.'); 249 } 250 } 251 252 // Start all the questions. 253 $variantstrategy = new core_question\engine\variants\least_used_strategy($quba, $qubaids); 254 255 if (!empty($forcedvariantsbyslot)) { 256 $forcedvariantsbyseed = question_variant_forced_choices_selection_strategy::prepare_forced_choices_array( 257 $forcedvariantsbyslot, $quba); 258 $variantstrategy = new question_variant_forced_choices_selection_strategy( 259 $forcedvariantsbyseed, $variantstrategy); 260 } 261 262 $quba->start_all_questions($variantstrategy, $timenow, $attempt->userid); 263 264 // Work out the attempt layout. 265 $sections = $quizobj->get_sections(); 266 foreach ($sections as $i => $section) { 267 if (isset($sections[$i + 1])) { 268 $sections[$i]->lastslot = $sections[$i + 1]->firstslot - 1; 269 } else { 270 $sections[$i]->lastslot = count($questions); 271 } 272 } 273 274 $layout = []; 275 foreach ($sections as $section) { 276 if ($section->shufflequestions) { 277 $questionsinthissection = []; 278 for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { 279 $questionsinthissection[] = $slot; 280 } 281 shuffle($questionsinthissection); 282 $questionsonthispage = 0; 283 foreach ($questionsinthissection as $slot) { 284 if ($questionsonthispage && $questionsonthispage == $quizobj->get_quiz()->questionsperpage) { 285 $layout[] = 0; 286 $questionsonthispage = 0; 287 } 288 $layout[] = $slot; 289 $questionsonthispage += 1; 290 } 291 292 } else { 293 $currentpage = $page[$section->firstslot]; 294 for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { 295 if ($currentpage !== null && $page[$slot] != $currentpage) { 296 $layout[] = 0; 297 } 298 $layout[] = $slot; 299 $currentpage = $page[$slot]; 300 } 301 } 302 303 // Each section ends with a page break. 304 $layout[] = 0; 305 } 306 $attempt->layout = implode(',', $layout); 307 308 return $attempt; 309 } 310 311 /** 312 * Start a subsequent new attempt, in each attempt builds on last mode. 313 * 314 * @param question_usage_by_activity $quba this question usage 315 * @param stdClass $attempt this attempt 316 * @param stdClass $lastattempt last attempt 317 * @return stdClass modified attempt object 318 * 319 */ 320 function quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt) { 321 $oldquba = question_engine::load_questions_usage_by_activity($lastattempt->uniqueid); 322 323 $oldnumberstonew = []; 324 foreach ($oldquba->get_attempt_iterator() as $oldslot => $oldqa) { 325 $question = $oldqa->get_question(false); 326 if ($question->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT) { 327 throw new moodle_exception('questiondraftonly', 'mod_quiz', '', $question->name); 328 } 329 $newslot = $quba->add_question($question, $oldqa->get_max_mark()); 330 331 $quba->start_question_based_on($newslot, $oldqa); 332 333 $oldnumberstonew[$oldslot] = $newslot; 334 } 335 336 // Update attempt layout. 337 $newlayout = []; 338 foreach (explode(',', $lastattempt->layout) as $oldslot) { 339 if ($oldslot != 0) { 340 $newlayout[] = $oldnumberstonew[$oldslot]; 341 } else { 342 $newlayout[] = 0; 343 } 344 } 345 $attempt->layout = implode(',', $newlayout); 346 return $attempt; 347 } 348 349 /** 350 * The save started question usage and quiz attempt in db and log the started attempt. 351 * 352 * @param quiz_settings $quizobj 353 * @param question_usage_by_activity $quba 354 * @param stdClass $attempt 355 * @return stdClass attempt object with uniqueid and id set. 356 */ 357 function quiz_attempt_save_started($quizobj, $quba, $attempt) { 358 global $DB; 359 // Save the attempt in the database. 360 question_engine::save_questions_usage_by_activity($quba); 361 $attempt->uniqueid = $quba->get_id(); 362 $attempt->id = $DB->insert_record('quiz_attempts', $attempt); 363 364 // Params used by the events below. 365 $params = [ 366 'objectid' => $attempt->id, 367 'relateduserid' => $attempt->userid, 368 'courseid' => $quizobj->get_courseid(), 369 'context' => $quizobj->get_context() 370 ]; 371 // Decide which event we are using. 372 if ($attempt->preview) { 373 $params['other'] = [ 374 'quizid' => $quizobj->get_quizid() 375 ]; 376 $event = \mod_quiz\event\attempt_preview_started::create($params); 377 } else { 378 $event = \mod_quiz\event\attempt_started::create($params); 379 380 } 381 382 // Trigger the event. 383 $event->add_record_snapshot('quiz', $quizobj->get_quiz()); 384 $event->add_record_snapshot('quiz_attempts', $attempt); 385 $event->trigger(); 386 387 return $attempt; 388 } 389 390 /** 391 * Returns an unfinished attempt (if there is one) for the given 392 * user on the given quiz. This function does not return preview attempts. 393 * 394 * @param int $quizid the id of the quiz. 395 * @param int $userid the id of the user. 396 * 397 * @return mixed the unfinished attempt if there is one, false if not. 398 */ 399 function quiz_get_user_attempt_unfinished($quizid, $userid) { 400 $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true); 401 if ($attempts) { 402 return array_shift($attempts); 403 } else { 404 return false; 405 } 406 } 407 408 /** 409 * Delete a quiz attempt. 410 * @param mixed $attempt an integer attempt id or an attempt object 411 * (row of the quiz_attempts table). 412 * @param stdClass $quiz the quiz object. 413 */ 414 function quiz_delete_attempt($attempt, $quiz) { 415 global $DB; 416 if (is_numeric($attempt)) { 417 if (!$attempt = $DB->get_record('quiz_attempts', ['id' => $attempt])) { 418 return; 419 } 420 } 421 422 if ($attempt->quiz != $quiz->id) { 423 debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " . 424 "but was passed quiz $quiz->id."); 425 return; 426 } 427 428 if (!isset($quiz->cmid)) { 429 $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); 430 $quiz->cmid = $cm->id; 431 } 432 433 question_engine::delete_questions_usage_by_activity($attempt->uniqueid); 434 $DB->delete_records('quiz_attempts', ['id' => $attempt->id]); 435 436 // Log the deletion of the attempt if not a preview. 437 if (!$attempt->preview) { 438 $params = [ 439 'objectid' => $attempt->id, 440 'relateduserid' => $attempt->userid, 441 'context' => context_module::instance($quiz->cmid), 442 'other' => [ 443 'quizid' => $quiz->id 444 ] 445 ]; 446 $event = \mod_quiz\event\attempt_deleted::create($params); 447 $event->add_record_snapshot('quiz_attempts', $attempt); 448 $event->trigger(); 449 450 $callbackclasses = \core_component::get_plugin_list_with_class('quiz', 'quiz_attempt_deleted'); 451 foreach ($callbackclasses as $callbackclass) { 452 component_class_callback($callbackclass, 'callback', [$quiz->id]); 453 } 454 } 455 456 // Search quiz_attempts for other instances by this user. 457 // If none, then delete record for this quiz, this user from quiz_grades 458 // else recalculate best grade. 459 $userid = $attempt->userid; 460 $gradecalculator = quiz_settings::create($quiz->id)->get_grade_calculator(); 461 if (!$DB->record_exists('quiz_attempts', ['userid' => $userid, 'quiz' => $quiz->id])) { 462 $DB->delete_records('quiz_grades', ['userid' => $userid, 'quiz' => $quiz->id]); 463 } else { 464 $gradecalculator->recompute_final_grade($userid); 465 } 466 467 quiz_update_grades($quiz, $userid); 468 } 469 470 /** 471 * Delete all the preview attempts at a quiz, or possibly all the attempts belonging 472 * to one user. 473 * @param stdClass $quiz the quiz object. 474 * @param int $userid (optional) if given, only delete the previews belonging to this user. 475 */ 476 function quiz_delete_previews($quiz, $userid = null) { 477 global $DB; 478 $conditions = ['quiz' => $quiz->id, 'preview' => 1]; 479 if (!empty($userid)) { 480 $conditions['userid'] = $userid; 481 } 482 $previewattempts = $DB->get_records('quiz_attempts', $conditions); 483 foreach ($previewattempts as $attempt) { 484 quiz_delete_attempt($attempt, $quiz); 485 } 486 } 487 488 /** 489 * @param int $quizid The quiz id. 490 * @return bool whether this quiz has any (non-preview) attempts. 491 */ 492 function quiz_has_attempts($quizid) { 493 global $DB; 494 return $DB->record_exists('quiz_attempts', ['quiz' => $quizid, 'preview' => 0]); 495 } 496 497 // Functions to do with quiz layout and pages ////////////////////////////////// 498 499 /** 500 * Repaginate the questions in a quiz 501 * @param int $quizid the id of the quiz to repaginate. 502 * @param int $slotsperpage number of items to put on each page. 0 means unlimited. 503 */ 504 function quiz_repaginate_questions($quizid, $slotsperpage) { 505 global $DB; 506 $trans = $DB->start_delegated_transaction(); 507 508 $sections = $DB->get_records('quiz_sections', ['quizid' => $quizid], 'firstslot ASC'); 509 $firstslots = []; 510 foreach ($sections as $section) { 511 if ((int)$section->firstslot === 1) { 512 continue; 513 } 514 $firstslots[] = $section->firstslot; 515 } 516 517 $slots = $DB->get_records('quiz_slots', ['quizid' => $quizid], 518 'slot'); 519 $currentpage = 1; 520 $slotsonthispage = 0; 521 foreach ($slots as $slot) { 522 if (($firstslots && in_array($slot->slot, $firstslots)) || 523 ($slotsonthispage && $slotsonthispage == $slotsperpage)) { 524 $currentpage += 1; 525 $slotsonthispage = 0; 526 } 527 if ($slot->page != $currentpage) { 528 $DB->set_field('quiz_slots', 'page', $currentpage, ['id' => $slot->id]); 529 } 530 $slotsonthispage += 1; 531 } 532 533 $trans->allow_commit(); 534 535 // Log quiz re-paginated event. 536 $cm = get_coursemodule_from_instance('quiz', $quizid); 537 $event = \mod_quiz\event\quiz_repaginated::create([ 538 'context' => \context_module::instance($cm->id), 539 'objectid' => $quizid, 540 'other' => [ 541 'slotsperpage' => $slotsperpage 542 ] 543 ]); 544 $event->trigger(); 545 546 } 547 548 // Functions to do with quiz grades //////////////////////////////////////////// 549 // Note a lot of logic related to this is now in the grade_calculator class. 550 551 /** 552 * Convert the raw grade stored in $attempt into a grade out of the maximum 553 * grade for this quiz. 554 * 555 * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades 556 * @param stdClass $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used. 557 * @param bool|string $format whether to format the results for display 558 * or 'question' to format a question grade (different number of decimal places. 559 * @return float|string the rescaled grade, or null/the lang string 'notyetgraded' 560 * if the $grade is null. 561 */ 562 function quiz_rescale_grade($rawgrade, $quiz, $format = true) { 563 if (is_null($rawgrade)) { 564 $grade = null; 565 } else if ($quiz->sumgrades >= grade_calculator::ALMOST_ZERO) { 566 $grade = $rawgrade * $quiz->grade / $quiz->sumgrades; 567 } else { 568 $grade = 0; 569 } 570 if ($format === 'question') { 571 $grade = quiz_format_question_grade($quiz, $grade); 572 } else if ($format) { 573 $grade = quiz_format_grade($quiz, $grade); 574 } 575 return $grade; 576 } 577 578 /** 579 * Get the feedback object for this grade on this quiz. 580 * 581 * @param float $grade a grade on this quiz. 582 * @param stdClass $quiz the quiz settings. 583 * @return false|stdClass the record object or false if there is not feedback for the given grade 584 * @since Moodle 3.1 585 */ 586 function quiz_feedback_record_for_grade($grade, $quiz) { 587 global $DB; 588 589 // With CBM etc, it is possible to get -ve grades, which would then not match 590 // any feedback. Therefore, we replace -ve grades with 0. 591 $grade = max($grade, 0); 592 593 $feedback = $DB->get_record_select('quiz_feedback', 594 'quizid = ? AND mingrade <= ? AND ? < maxgrade', [$quiz->id, $grade, $grade]); 595 596 return $feedback; 597 } 598 599 /** 600 * Get the feedback text that should be show to a student who 601 * got this grade on this quiz. The feedback is processed ready for diplay. 602 * 603 * @param float $grade a grade on this quiz. 604 * @param stdClass $quiz the quiz settings. 605 * @param context_module $context the quiz context. 606 * @return string the comment that corresponds to this grade (empty string if there is not one. 607 */ 608 function quiz_feedback_for_grade($grade, $quiz, $context) { 609 610 if (is_null($grade)) { 611 return ''; 612 } 613 614 $feedback = quiz_feedback_record_for_grade($grade, $quiz); 615 616 if (empty($feedback->feedbacktext)) { 617 return ''; 618 } 619 620 // Clean the text, ready for display. 621 $formatoptions = new stdClass(); 622 $formatoptions->noclean = true; 623 $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext, 'pluginfile.php', 624 $context->id, 'mod_quiz', 'feedback', $feedback->id); 625 $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions); 626 627 return $feedbacktext; 628 } 629 630 /** 631 * @param stdClass $quiz the quiz database row. 632 * @return bool Whether this quiz has any non-blank feedback text. 633 */ 634 function quiz_has_feedback($quiz) { 635 global $DB; 636 static $cache = []; 637 if (!array_key_exists($quiz->id, $cache)) { 638 $cache[$quiz->id] = quiz_has_grades($quiz) && 639 $DB->record_exists_select('quiz_feedback', "quizid = ? AND " . 640 $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true), 641 [$quiz->id]); 642 } 643 return $cache[$quiz->id]; 644 } 645 646 /** 647 * Return summary of the number of settings override that exist. 648 * 649 * To get a nice display of this, see the quiz_override_summary_links() 650 * quiz renderer method. 651 * 652 * @param stdClass $quiz the quiz settings. Only $quiz->id is used at the moment. 653 * @param cm_info|stdClass $cm the cm object. Only $cm->course, $cm->groupmode and 654 * $cm->groupingid fields are used at the moment. 655 * @param int $currentgroup if there is a concept of current group where this method is being called 656 * (e.g. a report) pass it in here. Default 0 which means no current group. 657 * @return array like 'group' => 3, 'user' => 12] where 3 is the number of group overrides, 658 * and 12 is the number of user ones. 659 */ 660 function quiz_override_summary(stdClass $quiz, cm_info|stdClass $cm, int $currentgroup = 0): array { 661 global $DB; 662 663 if ($currentgroup) { 664 // Currently only interested in one group. 665 $groupcount = $DB->count_records('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $currentgroup]); 666 $usercount = $DB->count_records_sql(" 667 SELECT COUNT(1) 668 FROM {quiz_overrides} o 669 JOIN {groups_members} gm ON o.userid = gm.userid 670 WHERE o.quiz = ? 671 AND gm.groupid = ? 672 ", [$quiz->id, $currentgroup]); 673 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'onegroup']; 674 } 675 676 $quizgroupmode = groups_get_activity_groupmode($cm); 677 $accessallgroups = ($quizgroupmode == NOGROUPS) || 678 has_capability('moodle/site:accessallgroups', context_module::instance($cm->id)); 679 680 if ($accessallgroups) { 681 // User can see all groups. 682 $groupcount = $DB->count_records_select('quiz_overrides', 683 'quiz = ? AND groupid IS NOT NULL', [$quiz->id]); 684 $usercount = $DB->count_records_select('quiz_overrides', 685 'quiz = ? AND userid IS NOT NULL', [$quiz->id]); 686 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'allgroups']; 687 688 } else { 689 // User can only see groups they are in. 690 $groups = groups_get_activity_allowed_groups($cm); 691 if (!$groups) { 692 return ['group' => 0, 'user' => 0, 'mode' => 'somegroups']; 693 } 694 695 list($groupidtest, $params) = $DB->get_in_or_equal(array_keys($groups)); 696 $params[] = $quiz->id; 697 698 $groupcount = $DB->count_records_select('quiz_overrides', 699 "groupid $groupidtest AND quiz = ?", $params); 700 $usercount = $DB->count_records_sql(" 701 SELECT COUNT(1) 702 FROM {quiz_overrides} o 703 JOIN {groups_members} gm ON o.userid = gm.userid 704 WHERE gm.groupid $groupidtest 705 AND o.quiz = ? 706 ", $params); 707 708 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'somegroups']; 709 } 710 } 711 712 /** 713 * Efficiently update check state time on all open attempts 714 * 715 * @param array $conditions optional restrictions on which attempts to update 716 * Allowed conditions: 717 * courseid => (array|int) attempts in given course(s) 718 * userid => (array|int) attempts for given user(s) 719 * quizid => (array|int) attempts in given quiz(s) 720 * groupid => (array|int) quizzes with some override for given group(s) 721 * 722 */ 723 function quiz_update_open_attempts(array $conditions) { 724 global $DB; 725 726 foreach ($conditions as &$value) { 727 if (!is_array($value)) { 728 $value = [$value]; 729 } 730 } 731 732 $params = []; 733 $wheres = ["quiza.state IN ('inprogress', 'overdue')"]; 734 $iwheres = ["iquiza.state IN ('inprogress', 'overdue')"]; 735 736 if (isset($conditions['courseid'])) { 737 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'cid'); 738 $params = array_merge($params, $inparams); 739 $wheres[] = "quiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)"; 740 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'icid'); 741 $params = array_merge($params, $inparams); 742 $iwheres[] = "iquiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)"; 743 } 744 745 if (isset($conditions['userid'])) { 746 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'uid'); 747 $params = array_merge($params, $inparams); 748 $wheres[] = "quiza.userid $incond"; 749 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'iuid'); 750 $params = array_merge($params, $inparams); 751 $iwheres[] = "iquiza.userid $incond"; 752 } 753 754 if (isset($conditions['quizid'])) { 755 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'qid'); 756 $params = array_merge($params, $inparams); 757 $wheres[] = "quiza.quiz $incond"; 758 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'iqid'); 759 $params = array_merge($params, $inparams); 760 $iwheres[] = "iquiza.quiz $incond"; 761 } 762 763 if (isset($conditions['groupid'])) { 764 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'gid'); 765 $params = array_merge($params, $inparams); 766 $wheres[] = "quiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)"; 767 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'igid'); 768 $params = array_merge($params, $inparams); 769 $iwheres[] = "iquiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)"; 770 } 771 772 // SQL to compute timeclose and timelimit for each attempt: 773 $quizausersql = quiz_get_attempt_usertime_sql( 774 implode("\n AND ", $iwheres)); 775 776 // SQL to compute the new timecheckstate 777 $timecheckstatesql = " 778 CASE WHEN quizauser.usertimelimit = 0 AND quizauser.usertimeclose = 0 THEN NULL 779 WHEN quizauser.usertimelimit = 0 THEN quizauser.usertimeclose 780 WHEN quizauser.usertimeclose = 0 THEN quiza.timestart + quizauser.usertimelimit 781 WHEN quiza.timestart + quizauser.usertimelimit < quizauser.usertimeclose THEN quiza.timestart + quizauser.usertimelimit 782 ELSE quizauser.usertimeclose END + 783 CASE WHEN quiza.state = 'overdue' THEN quiz.graceperiod ELSE 0 END"; 784 785 // SQL to select which attempts to process 786 $attemptselect = implode("\n AND ", $wheres); 787 788 /* 789 * Each database handles updates with inner joins differently: 790 * - mysql does not allow a FROM clause 791 * - postgres and mssql allow FROM but handle table aliases differently 792 * - oracle requires a subquery 793 * 794 * Different code for each database. 795 */ 796 797 $dbfamily = $DB->get_dbfamily(); 798 if ($dbfamily == 'mysql') { 799 $updatesql = "UPDATE {quiz_attempts} quiza 800 JOIN {quiz} quiz ON quiz.id = quiza.quiz 801 JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id 802 SET quiza.timecheckstate = $timecheckstatesql 803 WHERE $attemptselect"; 804 } else if ($dbfamily == 'postgres') { 805 $updatesql = "UPDATE {quiz_attempts} quiza 806 SET timecheckstate = $timecheckstatesql 807 FROM {quiz} quiz, ( $quizausersql ) quizauser 808 WHERE quiz.id = quiza.quiz 809 AND quizauser.id = quiza.id 810 AND $attemptselect"; 811 } else if ($dbfamily == 'mssql') { 812 $updatesql = "UPDATE quiza 813 SET timecheckstate = $timecheckstatesql 814 FROM {quiz_attempts} quiza 815 JOIN {quiz} quiz ON quiz.id = quiza.quiz 816 JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id 817 WHERE $attemptselect"; 818 } else { 819 // oracle, sqlite and others 820 $updatesql = "UPDATE {quiz_attempts} quiza 821 SET timecheckstate = ( 822 SELECT $timecheckstatesql 823 FROM {quiz} quiz, ( $quizausersql ) quizauser 824 WHERE quiz.id = quiza.quiz 825 AND quizauser.id = quiza.id 826 ) 827 WHERE $attemptselect"; 828 } 829 830 $DB->execute($updatesql, $params); 831 } 832 833 /** 834 * Returns SQL to compute timeclose and timelimit for every attempt, taking into account user and group overrides. 835 * The query used herein is very similar to the one in function quiz_get_user_timeclose, so, in case you 836 * would change either one of them, make sure to apply your changes to both. 837 * 838 * @param string $redundantwhereclauses extra where clauses to add to the subquery 839 * for performance. These can use the table alias iquiza for the quiz attempts table. 840 * @return string SQL select with columns attempt.id, usertimeclose, usertimelimit. 841 */ 842 function quiz_get_attempt_usertime_sql($redundantwhereclauses = '') { 843 if ($redundantwhereclauses) { 844 $redundantwhereclauses = 'WHERE ' . $redundantwhereclauses; 845 } 846 // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede 847 // any other group override 848 $quizausersql = " 849 SELECT iquiza.id, 850 COALESCE(MAX(quo.timeclose), MAX(qgo1.timeclose), MAX(qgo2.timeclose), iquiz.timeclose) AS usertimeclose, 851 COALESCE(MAX(quo.timelimit), MAX(qgo3.timelimit), MAX(qgo4.timelimit), iquiz.timelimit) AS usertimelimit 852 853 FROM {quiz_attempts} iquiza 854 JOIN {quiz} iquiz ON iquiz.id = iquiza.quiz 855 LEFT JOIN {quiz_overrides} quo ON quo.quiz = iquiza.quiz AND quo.userid = iquiza.userid 856 LEFT JOIN {groups_members} gm ON gm.userid = iquiza.userid 857 LEFT JOIN {quiz_overrides} qgo1 ON qgo1.quiz = iquiza.quiz AND qgo1.groupid = gm.groupid AND qgo1.timeclose = 0 858 LEFT JOIN {quiz_overrides} qgo2 ON qgo2.quiz = iquiza.quiz AND qgo2.groupid = gm.groupid AND qgo2.timeclose > 0 859 LEFT JOIN {quiz_overrides} qgo3 ON qgo3.quiz = iquiza.quiz AND qgo3.groupid = gm.groupid AND qgo3.timelimit = 0 860 LEFT JOIN {quiz_overrides} qgo4 ON qgo4.quiz = iquiza.quiz AND qgo4.groupid = gm.groupid AND qgo4.timelimit > 0 861 $redundantwhereclauses 862 GROUP BY iquiza.id, iquiz.id, iquiz.timeclose, iquiz.timelimit"; 863 return $quizausersql; 864 } 865 866 /** 867 * @return array int => lang string the options for calculating the quiz grade 868 * from the individual attempt grades. 869 */ 870 function quiz_get_grading_options() { 871 return [ 872 QUIZ_GRADEHIGHEST => get_string('gradehighest', 'quiz'), 873 QUIZ_GRADEAVERAGE => get_string('gradeaverage', 'quiz'), 874 QUIZ_ATTEMPTFIRST => get_string('attemptfirst', 'quiz'), 875 QUIZ_ATTEMPTLAST => get_string('attemptlast', 'quiz') 876 ]; 877 } 878 879 /** 880 * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, 881 * QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST. 882 * @return the lang string for that option. 883 */ 884 function quiz_get_grading_option_name($option) { 885 $strings = quiz_get_grading_options(); 886 return $strings[$option]; 887 } 888 889 /** 890 * @return array string => lang string the options for handling overdue quiz 891 * attempts. 892 */ 893 function quiz_get_overdue_handling_options() { 894 return [ 895 'autosubmit' => get_string('overduehandlingautosubmit', 'quiz'), 896 'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'), 897 'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'), 898 ]; 899 } 900 901 /** 902 * Get the choices for what size user picture to show. 903 * @return array string => lang string the options for whether to display the user's picture. 904 */ 905 function quiz_get_user_image_options() { 906 return [ 907 QUIZ_SHOWIMAGE_NONE => get_string('shownoimage', 'quiz'), 908 QUIZ_SHOWIMAGE_SMALL => get_string('showsmallimage', 'quiz'), 909 QUIZ_SHOWIMAGE_LARGE => get_string('showlargeimage', 'quiz'), 910 ]; 911 } 912 913 /** 914 * Return an user's timeclose for all quizzes in a course, hereby taking into account group and user overrides. 915 * 916 * @param int $courseid the course id. 917 * @return stdClass An object with of all quizids and close unixdates in this course, taking into account the most lenient 918 * overrides, if existing and 0 if no close date is set. 919 */ 920 function quiz_get_user_timeclose($courseid) { 921 global $DB, $USER; 922 923 // For teacher and manager/admins return timeclose. 924 if (has_capability('moodle/course:update', context_course::instance($courseid))) { 925 $sql = "SELECT quiz.id, quiz.timeclose AS usertimeclose 926 FROM {quiz} quiz 927 WHERE quiz.course = :courseid"; 928 929 $results = $DB->get_records_sql($sql, ['courseid' => $courseid]); 930 return $results; 931 } 932 933 $sql = "SELECT q.id, 934 COALESCE(v.userclose, v.groupclose, q.timeclose, 0) AS usertimeclose 935 FROM ( 936 SELECT quiz.id as quizid, 937 MAX(quo.timeclose) AS userclose, MAX(qgo.timeclose) AS groupclose 938 FROM {quiz} quiz 939 LEFT JOIN {quiz_overrides} quo on quiz.id = quo.quiz AND quo.userid = :userid 940 LEFT JOIN {groups_members} gm ON gm.userid = :useringroupid 941 LEFT JOIN {quiz_overrides} qgo on quiz.id = qgo.quiz AND qgo.groupid = gm.groupid 942 WHERE quiz.course = :courseid 943 GROUP BY quiz.id) v 944 JOIN {quiz} q ON q.id = v.quizid"; 945 946 $results = $DB->get_records_sql($sql, ['userid' => $USER->id, 'useringroupid' => $USER->id, 'courseid' => $courseid]); 947 return $results; 948 949 } 950 951 /** 952 * Get the choices to offer for the 'Questions per page' option. 953 * @return array int => string. 954 */ 955 function quiz_questions_per_page_options() { 956 $pageoptions = []; 957 $pageoptions[0] = get_string('neverallononepage', 'quiz'); 958 $pageoptions[1] = get_string('everyquestion', 'quiz'); 959 for ($i = 2; $i <= QUIZ_MAX_QPP_OPTION; ++$i) { 960 $pageoptions[$i] = get_string('everynquestions', 'quiz', $i); 961 } 962 return $pageoptions; 963 } 964 965 /** 966 * Get the human-readable name for a quiz attempt state. 967 * @param string $state one of the state constants like {@see quiz_attempt::IN_PROGRESS}. 968 * @return string The lang string to describe that state. 969 */ 970 function quiz_attempt_state_name($state) { 971 switch ($state) { 972 case quiz_attempt::IN_PROGRESS: 973 return get_string('stateinprogress', 'quiz'); 974 case quiz_attempt::OVERDUE: 975 return get_string('stateoverdue', 'quiz'); 976 case quiz_attempt::FINISHED: 977 return get_string('statefinished', 'quiz'); 978 case quiz_attempt::ABANDONED: 979 return get_string('stateabandoned', 'quiz'); 980 default: 981 throw new coding_exception('Unknown quiz attempt state.'); 982 } 983 } 984 985 // Other quiz functions //////////////////////////////////////////////////////// 986 987 /** 988 * @param stdClass $quiz the quiz. 989 * @param int $cmid the course_module object for this quiz. 990 * @param stdClass $question the question. 991 * @param string $returnurl url to return to after action is done. 992 * @param int $variant which question variant to preview (optional). 993 * @return string html for a number of icons linked to action pages for a 994 * question - preview and edit / view icons depending on user capabilities. 995 */ 996 function quiz_question_action_icons($quiz, $cmid, $question, $returnurl, $variant = null) { 997 $html = ''; 998 if ($question->qtype !== 'random') { 999 $html = quiz_question_preview_button($quiz, $question, false, $variant); 1000 } 1001 $html .= quiz_question_edit_button($cmid, $question, $returnurl); 1002 return $html; 1003 } 1004 1005 /** 1006 * @param int $cmid the course_module.id for this quiz. 1007 * @param stdClass $question the question. 1008 * @param string $returnurl url to return to after action is done. 1009 * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon. 1010 * @return the HTML for an edit icon, view icon, or nothing for a question 1011 * (depending on permissions). 1012 */ 1013 function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') { 1014 global $CFG, $OUTPUT; 1015 1016 // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page. 1017 static $stredit = null; 1018 static $strview = null; 1019 if ($stredit === null) { 1020 $stredit = get_string('edit'); 1021 $strview = get_string('view'); 1022 } 1023 1024 // What sort of icon should we show? 1025 $action = ''; 1026 if (!empty($question->id) && 1027 (question_has_capability_on($question, 'edit') || 1028 question_has_capability_on($question, 'move'))) { 1029 $action = $stredit; 1030 $icon = 't/edit'; 1031 } else if (!empty($question->id) && 1032 question_has_capability_on($question, 'view')) { 1033 $action = $strview; 1034 $icon = 'i/info'; 1035 } 1036 1037 // Build the icon. 1038 if ($action) { 1039 if ($returnurl instanceof moodle_url) { 1040 $returnurl = $returnurl->out_as_local_url(false); 1041 } 1042 $questionparams = ['returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id]; 1043 $questionurl = new moodle_url("$CFG->wwwroot/question/bank/editquestion/question.php", $questionparams); 1044 return '<a title="' . $action . '" href="' . $questionurl->out() . '" class="questioneditbutton">' . 1045 $OUTPUT->pix_icon($icon, $action) . $contentaftericon . 1046 '</a>'; 1047 } else if ($contentaftericon) { 1048 return '<span class="questioneditbutton">' . $contentaftericon . '</span>'; 1049 } else { 1050 return ''; 1051 } 1052 } 1053 1054 /** 1055 * @param stdClass $quiz the quiz settings 1056 * @param stdClass $question the question 1057 * @param int $variant which question variant to preview (optional). 1058 * @param int $restartversion version of the question to use when restarting the preview. 1059 * @return moodle_url to preview this question with the options from this quiz. 1060 */ 1061 function quiz_question_preview_url($quiz, $question, $variant = null, $restartversion = null) { 1062 // Get the appropriate display options. 1063 $displayoptions = display_options::make_from_quiz($quiz, 1064 display_options::DURING); 1065 1066 $maxmark = null; 1067 if (isset($question->maxmark)) { 1068 $maxmark = $question->maxmark; 1069 } 1070 1071 // Work out the correcte preview URL. 1072 return \qbank_previewquestion\helper::question_preview_url($question->id, $quiz->preferredbehaviour, 1073 $maxmark, $displayoptions, $variant, null, null, $restartversion); 1074 } 1075 1076 /** 1077 * @param stdClass $quiz the quiz settings 1078 * @param stdClass $question the question 1079 * @param bool $label if true, show the preview question label after the icon 1080 * @param int $variant which question variant to preview (optional). 1081 * @param bool $random if question is random, true. 1082 * @return string the HTML for a preview question icon. 1083 */ 1084 function quiz_question_preview_button($quiz, $question, $label = false, $variant = null, $random = null) { 1085 global $PAGE; 1086 if (!question_has_capability_on($question, 'use')) { 1087 return ''; 1088 } 1089 $structure = quiz_settings::create($quiz->id)->get_structure(); 1090 if (!empty($question->slot)) { 1091 $requestedversion = $structure->get_slot_by_number($question->slot)->requestedversion 1092 ?? question_preview_options::ALWAYS_LATEST; 1093 } else { 1094 $requestedversion = question_preview_options::ALWAYS_LATEST; 1095 } 1096 return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon( 1097 $quiz, $question, $label, $variant, $requestedversion); 1098 } 1099 1100 /** 1101 * @param stdClass $attempt the attempt. 1102 * @param stdClass $context the quiz context. 1103 * @return int whether flags should be shown/editable to the current user for this attempt. 1104 */ 1105 function quiz_get_flag_option($attempt, $context) { 1106 global $USER; 1107 if (!has_capability('moodle/question:flag', $context)) { 1108 return question_display_options::HIDDEN; 1109 } else if ($attempt->userid == $USER->id) { 1110 return question_display_options::EDITABLE; 1111 } else { 1112 return question_display_options::VISIBLE; 1113 } 1114 } 1115 1116 /** 1117 * Work out what state this quiz attempt is in - in the sense used by 1118 * quiz_get_review_options, not in the sense of $attempt->state. 1119 * @param stdClass $quiz the quiz settings 1120 * @param stdClass $attempt the quiz_attempt database row. 1121 * @return int one of the display_options::DURING, 1122 * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. 1123 */ 1124 function quiz_attempt_state($quiz, $attempt) { 1125 if ($attempt->state == quiz_attempt::IN_PROGRESS) { 1126 return display_options::DURING; 1127 } else if ($quiz->timeclose && time() >= $quiz->timeclose) { 1128 return display_options::AFTER_CLOSE; 1129 } else if (time() < $attempt->timefinish + 120) { 1130 return display_options::IMMEDIATELY_AFTER; 1131 } else { 1132 return display_options::LATER_WHILE_OPEN; 1133 } 1134 } 1135 1136 /** 1137 * The appropriate display_options object for this attempt at this quiz right now. 1138 * 1139 * @param stdClass $quiz the quiz instance. 1140 * @param stdClass $attempt the attempt in question. 1141 * @param context $context the quiz context. 1142 * 1143 * @return display_options 1144 */ 1145 function quiz_get_review_options($quiz, $attempt, $context) { 1146 $options = display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt)); 1147 1148 $options->readonly = true; 1149 $options->flags = quiz_get_flag_option($attempt, $context); 1150 if (!empty($attempt->id)) { 1151 $options->questionreviewlink = new moodle_url('/mod/quiz/reviewquestion.php', 1152 ['attempt' => $attempt->id]); 1153 } 1154 1155 // Show a link to the comment box only for closed attempts. 1156 if (!empty($attempt->id) && $attempt->state == quiz_attempt::FINISHED && !$attempt->preview && 1157 !is_null($context) && has_capability('mod/quiz:grade', $context)) { 1158 $options->manualcomment = question_display_options::VISIBLE; 1159 $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php', 1160 ['attempt' => $attempt->id]); 1161 } 1162 1163 if (!is_null($context) && !$attempt->preview && 1164 has_capability('mod/quiz:viewreports', $context) && 1165 has_capability('moodle/grade:viewhidden', $context)) { 1166 // People who can see reports and hidden grades should be shown everything, 1167 // except during preview when teachers want to see what students see. 1168 $options->attempt = question_display_options::VISIBLE; 1169 $options->correctness = question_display_options::VISIBLE; 1170 $options->marks = question_display_options::MARK_AND_MAX; 1171 $options->feedback = question_display_options::VISIBLE; 1172 $options->numpartscorrect = question_display_options::VISIBLE; 1173 $options->manualcomment = question_display_options::VISIBLE; 1174 $options->generalfeedback = question_display_options::VISIBLE; 1175 $options->rightanswer = question_display_options::VISIBLE; 1176 $options->overallfeedback = question_display_options::VISIBLE; 1177 $options->history = question_display_options::VISIBLE; 1178 $options->userinfoinhistory = $attempt->userid; 1179 1180 } 1181 1182 return $options; 1183 } 1184 1185 /** 1186 * Combines the review options from a number of different quiz attempts. 1187 * Returns an array of two ojects, so the suggested way of calling this 1188 * funciton is: 1189 * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...) 1190 * 1191 * @param stdClass $quiz the quiz instance. 1192 * @param array $attempts an array of attempt objects. 1193 * 1194 * @return array of two options objects, one showing which options are true for 1195 * at least one of the attempts, the other showing which options are true 1196 * for all attempts. 1197 */ 1198 function quiz_get_combined_reviewoptions($quiz, $attempts) { 1199 $fields = ['feedback', 'generalfeedback', 'rightanswer', 'overallfeedback']; 1200 $someoptions = new stdClass(); 1201 $alloptions = new stdClass(); 1202 foreach ($fields as $field) { 1203 $someoptions->$field = false; 1204 $alloptions->$field = true; 1205 } 1206 $someoptions->marks = question_display_options::HIDDEN; 1207 $alloptions->marks = question_display_options::MARK_AND_MAX; 1208 1209 // This shouldn't happen, but we need to prevent reveal information. 1210 if (empty($attempts)) { 1211 return [$someoptions, $someoptions]; 1212 } 1213 1214 foreach ($attempts as $attempt) { 1215 $attemptoptions = display_options::make_from_quiz($quiz, 1216 quiz_attempt_state($quiz, $attempt)); 1217 foreach ($fields as $field) { 1218 $someoptions->$field = $someoptions->$field || $attemptoptions->$field; 1219 $alloptions->$field = $alloptions->$field && $attemptoptions->$field; 1220 } 1221 $someoptions->marks = max($someoptions->marks, $attemptoptions->marks); 1222 $alloptions->marks = min($alloptions->marks, $attemptoptions->marks); 1223 } 1224 return [$someoptions, $alloptions]; 1225 } 1226 1227 // Functions for sending notification messages ///////////////////////////////// 1228 1229 /** 1230 * Sends a confirmation message to the student confirming that the attempt was processed. 1231 * 1232 * @param stdClass $recipient user object for the recipient. 1233 * @param stdClass $a lots of useful information that can be used in the message 1234 * subject and body. 1235 * @param bool $studentisonline is the student currently interacting with Moodle? 1236 * 1237 * @return int|false as for {@link message_send()}. 1238 */ 1239 function quiz_send_confirmation($recipient, $a, $studentisonline) { 1240 1241 // Add information about the recipient to $a. 1242 // Don't do idnumber. we want idnumber to be the submitter's idnumber. 1243 $a->username = fullname($recipient); 1244 $a->userusername = $recipient->username; 1245 1246 // Prepare the message. 1247 $eventdata = new \core\message\message(); 1248 $eventdata->courseid = $a->courseid; 1249 $eventdata->component = 'mod_quiz'; 1250 $eventdata->name = 'confirmation'; 1251 $eventdata->notification = 1; 1252 1253 $eventdata->userfrom = core_user::get_noreply_user(); 1254 $eventdata->userto = $recipient; 1255 $eventdata->subject = get_string('emailconfirmsubject', 'quiz', $a); 1256 1257 if ($studentisonline) { 1258 $eventdata->fullmessage = get_string('emailconfirmbody', 'quiz', $a); 1259 } else { 1260 $eventdata->fullmessage = get_string('emailconfirmbodyautosubmit', 'quiz', $a); 1261 } 1262 1263 $eventdata->fullmessageformat = FORMAT_PLAIN; 1264 $eventdata->fullmessagehtml = ''; 1265 1266 $eventdata->smallmessage = get_string('emailconfirmsmall', 'quiz', $a); 1267 $eventdata->contexturl = $a->quizurl; 1268 $eventdata->contexturlname = $a->quizname; 1269 $eventdata->customdata = [ 1270 'cmid' => $a->quizcmid, 1271 'instance' => $a->quizid, 1272 'attemptid' => $a->attemptid, 1273 ]; 1274 1275 // ... and send it. 1276 return message_send($eventdata); 1277 } 1278 1279 /** 1280 * Sends notification messages to the interested parties that assign the role capability 1281 * 1282 * @param stdClass $recipient user object of the intended recipient 1283 * @param stdClass $submitter user object for the user who submitted the attempt. 1284 * @param stdClass $a associative array of replaceable fields for the templates 1285 * 1286 * @return int|false as for {@link message_send()}. 1287 */ 1288 function quiz_send_notification($recipient, $submitter, $a) { 1289 global $PAGE; 1290 1291 // Recipient info for template. 1292 $a->useridnumber = $recipient->idnumber; 1293 $a->username = fullname($recipient); 1294 $a->userusername = $recipient->username; 1295 1296 // Prepare the message. 1297 $eventdata = new \core\message\message(); 1298 $eventdata->courseid = $a->courseid; 1299 $eventdata->component = 'mod_quiz'; 1300 $eventdata->name = 'submission'; 1301 $eventdata->notification = 1; 1302 1303 $eventdata->userfrom = $submitter; 1304 $eventdata->userto = $recipient; 1305 $eventdata->subject = get_string('emailnotifysubject', 'quiz', $a); 1306 $eventdata->fullmessage = get_string('emailnotifybody', 'quiz', $a); 1307 $eventdata->fullmessageformat = FORMAT_PLAIN; 1308 $eventdata->fullmessagehtml = ''; 1309 1310 $eventdata->smallmessage = get_string('emailnotifysmall', 'quiz', $a); 1311 $eventdata->contexturl = $a->quizreviewurl; 1312 $eventdata->contexturlname = $a->quizname; 1313 $userpicture = new user_picture($submitter); 1314 $userpicture->size = 1; // Use f1 size. 1315 $userpicture->includetoken = $recipient->id; // Generate an out-of-session token for the user receiving the message. 1316 $eventdata->customdata = [ 1317 'cmid' => $a->quizcmid, 1318 'instance' => $a->quizid, 1319 'attemptid' => $a->attemptid, 1320 'notificationiconurl' => $userpicture->get_url($PAGE)->out(false), 1321 ]; 1322 1323 // ... and send it. 1324 return message_send($eventdata); 1325 } 1326 1327 /** 1328 * Send all the requried messages when a quiz attempt is submitted. 1329 * 1330 * @param stdClass $course the course 1331 * @param stdClass $quiz the quiz 1332 * @param stdClass $attempt this attempt just finished 1333 * @param stdClass $context the quiz context 1334 * @param stdClass $cm the coursemodule for this quiz 1335 * @param bool $studentisonline is the student currently interacting with Moodle? 1336 * 1337 * @return bool true if all necessary messages were sent successfully, else false. 1338 */ 1339 function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm, $studentisonline) { 1340 global $CFG, $DB; 1341 1342 // Do nothing if required objects not present. 1343 if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) { 1344 throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.'); 1345 } 1346 1347 $submitter = $DB->get_record('user', ['id' => $attempt->userid], '*', MUST_EXIST); 1348 1349 // Check for confirmation required. 1350 $sendconfirm = false; 1351 $notifyexcludeusers = ''; 1352 if (has_capability('mod/quiz:emailconfirmsubmission', $context, $submitter, false)) { 1353 $notifyexcludeusers = $submitter->id; 1354 $sendconfirm = true; 1355 } 1356 1357 // Check for notifications required. 1358 $notifyfields = 'u.id, u.username, u.idnumber, u.email, u.emailstop, u.lang, 1359 u.timezone, u.mailformat, u.maildisplay, u.auth, u.suspended, u.deleted, '; 1360 $userfieldsapi = \core_user\fields::for_name(); 1361 $notifyfields .= $userfieldsapi->get_sql('u', false, '', '', false)->selects; 1362 $groups = groups_get_all_groups($course->id, $submitter->id, $cm->groupingid); 1363 if (is_array($groups) && count($groups) > 0) { 1364 $groups = array_keys($groups); 1365 } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) { 1366 // If the user is not in a group, and the quiz is set to group mode, 1367 // then set $groups to a non-existant id so that only users with 1368 // 'moodle/site:accessallgroups' get notified. 1369 $groups = -1; 1370 } else { 1371 $groups = ''; 1372 } 1373 $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission', 1374 $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true); 1375 1376 if (empty($userstonotify) && !$sendconfirm) { 1377 return true; // Nothing to do. 1378 } 1379 1380 $a = new stdClass(); 1381 // Course info. 1382 $a->courseid = $course->id; 1383 $a->coursename = $course->fullname; 1384 $a->courseshortname = $course->shortname; 1385 // Quiz info. 1386 $a->quizname = $quiz->name; 1387 $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id; 1388 $a->quizreportlink = '<a href="' . $a->quizreporturl . '">' . 1389 format_string($quiz->name) . ' report</a>'; 1390 $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; 1391 $a->quizlink = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>'; 1392 $a->quizid = $quiz->id; 1393 $a->quizcmid = $cm->id; 1394 // Attempt info. 1395 $a->submissiontime = userdate($attempt->timefinish); 1396 $a->timetaken = format_time($attempt->timefinish - $attempt->timestart); 1397 $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id; 1398 $a->quizreviewlink = '<a href="' . $a->quizreviewurl . '">' . 1399 format_string($quiz->name) . ' review</a>'; 1400 $a->attemptid = $attempt->id; 1401 // Student who sat the quiz info. 1402 $a->studentidnumber = $submitter->idnumber; 1403 $a->studentname = fullname($submitter); 1404 $a->studentusername = $submitter->username; 1405 1406 $allok = true; 1407 1408 // Send notifications if required. 1409 if (!empty($userstonotify)) { 1410 foreach ($userstonotify as $recipient) { 1411 $allok = $allok && quiz_send_notification($recipient, $submitter, $a); 1412 } 1413 } 1414 1415 // Send confirmation if required. We send the student confirmation last, so 1416 // that if message sending is being intermittently buggy, which means we send 1417 // some but not all messages, and then try again later, then teachers may get 1418 // duplicate messages, but the student will always get exactly one. 1419 if ($sendconfirm) { 1420 $allok = $allok && quiz_send_confirmation($submitter, $a, $studentisonline); 1421 } 1422 1423 return $allok; 1424 } 1425 1426 /** 1427 * Send the notification message when a quiz attempt becomes overdue. 1428 * 1429 * @param quiz_attempt $attemptobj all the data about the quiz attempt. 1430 */ 1431 function quiz_send_overdue_message($attemptobj) { 1432 global $CFG, $DB; 1433 1434 $submitter = $DB->get_record('user', ['id' => $attemptobj->get_userid()], '*', MUST_EXIST); 1435 1436 if (!$attemptobj->has_capability('mod/quiz:emailwarnoverdue', $submitter->id, false)) { 1437 return; // Message not required. 1438 } 1439 1440 if (!$attemptobj->has_response_to_at_least_one_graded_question()) { 1441 return; // Message not required. 1442 } 1443 1444 // Prepare lots of useful information that admins might want to include in 1445 // the email message. 1446 $quizname = format_string($attemptobj->get_quiz_name()); 1447 1448 $deadlines = []; 1449 if ($attemptobj->get_quiz()->timelimit) { 1450 $deadlines[] = $attemptobj->get_attempt()->timestart + $attemptobj->get_quiz()->timelimit; 1451 } 1452 if ($attemptobj->get_quiz()->timeclose) { 1453 $deadlines[] = $attemptobj->get_quiz()->timeclose; 1454 } 1455 $duedate = min($deadlines); 1456 $graceend = $duedate + $attemptobj->get_quiz()->graceperiod; 1457 1458 $a = new stdClass(); 1459 // Course info. 1460 $a->courseid = $attemptobj->get_course()->id; 1461 $a->coursename = format_string($attemptobj->get_course()->fullname); 1462 $a->courseshortname = format_string($attemptobj->get_course()->shortname); 1463 // Quiz info. 1464 $a->quizname = $quizname; 1465 $a->quizurl = $attemptobj->view_url()->out(false); 1466 $a->quizlink = '<a href="' . $a->quizurl . '">' . $quizname . '</a>'; 1467 // Attempt info. 1468 $a->attemptduedate = userdate($duedate); 1469 $a->attemptgraceend = userdate($graceend); 1470 $a->attemptsummaryurl = $attemptobj->summary_url()->out(false); 1471 $a->attemptsummarylink = '<a href="' . $a->attemptsummaryurl . '">' . $quizname . ' review</a>'; 1472 // Student's info. 1473 $a->studentidnumber = $submitter->idnumber; 1474 $a->studentname = fullname($submitter); 1475 $a->studentusername = $submitter->username; 1476 1477 // Prepare the message. 1478 $eventdata = new \core\message\message(); 1479 $eventdata->courseid = $a->courseid; 1480 $eventdata->component = 'mod_quiz'; 1481 $eventdata->name = 'attempt_overdue'; 1482 $eventdata->notification = 1; 1483 1484 $eventdata->userfrom = core_user::get_noreply_user(); 1485 $eventdata->userto = $submitter; 1486 $eventdata->subject = get_string('emailoverduesubject', 'quiz', $a); 1487 $eventdata->fullmessage = get_string('emailoverduebody', 'quiz', $a); 1488 $eventdata->fullmessageformat = FORMAT_PLAIN; 1489 $eventdata->fullmessagehtml = ''; 1490 1491 $eventdata->smallmessage = get_string('emailoverduesmall', 'quiz', $a); 1492 $eventdata->contexturl = $a->quizurl; 1493 $eventdata->contexturlname = $a->quizname; 1494 $eventdata->customdata = [ 1495 'cmid' => $attemptobj->get_cmid(), 1496 'instance' => $attemptobj->get_quizid(), 1497 'attemptid' => $attemptobj->get_attemptid(), 1498 ]; 1499 1500 // Send the message. 1501 return message_send($eventdata); 1502 } 1503 1504 /** 1505 * Handle the quiz_attempt_submitted event. 1506 * 1507 * This sends the confirmation and notification messages, if required. 1508 * 1509 * @param attempt_submitted $event the event object. 1510 */ 1511 function quiz_attempt_submitted_handler($event) { 1512 $course = get_course($event->courseid); 1513 $attempt = $event->get_record_snapshot('quiz_attempts', $event->objectid); 1514 $quiz = $event->get_record_snapshot('quiz', $attempt->quiz); 1515 $cm = get_coursemodule_from_id('quiz', $event->get_context()->instanceid, $event->courseid); 1516 $eventdata = $event->get_data(); 1517 1518 if (!($course && $quiz && $cm && $attempt)) { 1519 // Something has been deleted since the event was raised. Therefore, the 1520 // event is no longer relevant. 1521 return true; 1522 } 1523 1524 // Update completion state. 1525 $completion = new completion_info($course); 1526 if ($completion->is_enabled($cm) && 1527 ($quiz->completionattemptsexhausted || $quiz->completionminattempts)) { 1528 $completion->update_state($cm, COMPLETION_COMPLETE, $event->userid); 1529 } 1530 return quiz_send_notification_messages($course, $quiz, $attempt, 1531 context_module::instance($cm->id), $cm, $eventdata['other']['studentisonline']); 1532 } 1533 1534 /** 1535 * Send the notification message when a quiz attempt has been manual graded. 1536 * 1537 * @param quiz_attempt $attemptobj Some data about the quiz attempt. 1538 * @param stdClass $userto 1539 * @return int|false As for message_send. 1540 */ 1541 function quiz_send_notify_manual_graded_message(quiz_attempt $attemptobj, object $userto): ?int { 1542 global $CFG; 1543 1544 $quizname = format_string($attemptobj->get_quiz_name()); 1545 1546 $a = new stdClass(); 1547 // Course info. 1548 $a->courseid = $attemptobj->get_courseid(); 1549 $a->coursename = format_string($attemptobj->get_course()->fullname); 1550 // Quiz info. 1551 $a->quizname = $quizname; 1552 $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $attemptobj->get_cmid(); 1553 1554 // Attempt info. 1555 $a->attempttimefinish = userdate($attemptobj->get_attempt()->timefinish); 1556 // Student's info. 1557 $a->studentidnumber = $userto->idnumber; 1558 $a->studentname = fullname($userto); 1559 1560 $eventdata = new \core\message\message(); 1561 $eventdata->component = 'mod_quiz'; 1562 $eventdata->name = 'attempt_grading_complete'; 1563 $eventdata->userfrom = core_user::get_noreply_user(); 1564 $eventdata->userto = $userto; 1565 1566 $eventdata->subject = get_string('emailmanualgradedsubject', 'quiz', $a); 1567 $eventdata->fullmessage = get_string('emailmanualgradedbody', 'quiz', $a); 1568 $eventdata->fullmessageformat = FORMAT_PLAIN; 1569 $eventdata->fullmessagehtml = ''; 1570 1571 $eventdata->notification = 1; 1572 $eventdata->contexturl = $a->quizurl; 1573 $eventdata->contexturlname = $a->quizname; 1574 1575 // Send the message. 1576 return message_send($eventdata); 1577 } 1578 1579 1580 /** 1581 * Logic to happen when a/some group(s) has/have been deleted in a course. 1582 * 1583 * @param int $courseid The course ID. 1584 * @return void 1585 */ 1586 function quiz_process_group_deleted_in_course($courseid) { 1587 global $DB; 1588 1589 // It would be nice if we got the groupid that was deleted. 1590 // Instead, we just update all quizzes with orphaned group overrides. 1591 $sql = "SELECT o.id, o.quiz, o.groupid 1592 FROM {quiz_overrides} o 1593 JOIN {quiz} quiz ON quiz.id = o.quiz 1594 LEFT JOIN {groups} grp ON grp.id = o.groupid 1595 WHERE quiz.course = :courseid 1596 AND o.groupid IS NOT NULL 1597 AND grp.id IS NULL"; 1598 $params = ['courseid' => $courseid]; 1599 $records = $DB->get_records_sql($sql, $params); 1600 if (!$records) { 1601 return; // Nothing to do. 1602 } 1603 $DB->delete_records_list('quiz_overrides', 'id', array_keys($records)); 1604 $cache = cache::make('mod_quiz', 'overrides'); 1605 foreach ($records as $record) { 1606 $cache->delete("{$record->quiz}_g_{$record->groupid}"); 1607 } 1608 quiz_update_open_attempts(['quizid' => array_unique(array_column($records, 'quiz'))]); 1609 } 1610 1611 /** 1612 * Get the information about the standard quiz JavaScript module. 1613 * @return array a standard jsmodule structure. 1614 */ 1615 function quiz_get_js_module() { 1616 global $PAGE; 1617 1618 return [ 1619 'name' => 'mod_quiz', 1620 'fullpath' => '/mod/quiz/module.js', 1621 'requires' => ['base', 'dom', 'event-delegate', 'event-key', 1622 'core_question_engine'], 1623 'strings' => [ 1624 ['cancel', 'moodle'], 1625 ['flagged', 'question'], 1626 ['functiondisabledbysecuremode', 'quiz'], 1627 ['startattempt', 'quiz'], 1628 ['timesup', 'quiz'], 1629 ], 1630 ]; 1631 } 1632 1633 1634 /** 1635 * Creates a textual representation of a question for display. 1636 * 1637 * @param stdClass $question A question object from the database questions table 1638 * @param bool $showicon If true, show the question's icon with the question. False by default. 1639 * @param bool $showquestiontext If true (default), show question text after question name. 1640 * If false, show only question name. 1641 * @param bool $showidnumber If true, show the question's idnumber, if any. False by default. 1642 * @param core_tag_tag[]|bool $showtags if array passed, show those tags. Else, if true, get and show tags, 1643 * else, don't show tags (which is the default). 1644 * @return string HTML fragment. 1645 */ 1646 function quiz_question_tostring($question, $showicon = false, $showquestiontext = true, 1647 $showidnumber = false, $showtags = false) { 1648 global $OUTPUT; 1649 $result = ''; 1650 1651 // Question name. 1652 $name = shorten_text(format_string($question->name), 200); 1653 if ($showicon) { 1654 $name .= print_question_icon($question) . ' ' . $name; 1655 } 1656 $result .= html_writer::span($name, 'questionname'); 1657 1658 // Question idnumber. 1659 if ($showidnumber && $question->idnumber !== null && $question->idnumber !== '') { 1660 $result .= ' ' . html_writer::span( 1661 html_writer::span(get_string('idnumber', 'question'), 'accesshide') . 1662 ' ' . s($question->idnumber), 'badge badge-primary'); 1663 } 1664 1665 // Question tags. 1666 if (is_array($showtags)) { 1667 $tags = $showtags; 1668 } else if ($showtags) { 1669 $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); 1670 } else { 1671 $tags = []; 1672 } 1673 if ($tags) { 1674 $result .= $OUTPUT->tag_list($tags, null, 'd-inline', 0, null, true); 1675 } 1676 1677 // Question text. 1678 if ($showquestiontext) { 1679 $questiontext = question_utils::to_plain_text($question->questiontext, 1680 $question->questiontextformat, ['noclean' => true, 'para' => false, 'filter' => false]); 1681 $questiontext = shorten_text($questiontext, 50); 1682 if ($questiontext) { 1683 $result .= ' ' . html_writer::span(s($questiontext), 'questiontext'); 1684 } 1685 } 1686 1687 return $result; 1688 } 1689 1690 /** 1691 * Verify that the question exists, and the user has permission to use it. 1692 * Does not return. Throws an exception if the question cannot be used. 1693 * @param int $questionid The id of the question. 1694 */ 1695 function quiz_require_question_use($questionid) { 1696 global $DB; 1697 $question = $DB->get_record('question', ['id' => $questionid], '*', MUST_EXIST); 1698 question_require_capability_on($question, 'use'); 1699 } 1700 1701 /** 1702 * Add a question to a quiz 1703 * 1704 * Adds a question to a quiz by updating $quiz as well as the 1705 * quiz and quiz_slots tables. It also adds a page break if required. 1706 * @param int $questionid The id of the question to be added 1707 * @param stdClass $quiz The extended quiz object as used by edit.php 1708 * This is updated by this function 1709 * @param int $page Which page in quiz to add the question on. If 0 (default), 1710 * add at the end 1711 * @param float $maxmark The maximum mark to set for this question. (Optional, 1712 * defaults to question.defaultmark. 1713 * @return bool false if the question was already in the quiz 1714 */ 1715 function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) { 1716 global $DB; 1717 1718 if (!isset($quiz->cmid)) { 1719 $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); 1720 $quiz->cmid = $cm->id; 1721 } 1722 1723 // Make sue the question is not of the "random" type. 1724 $questiontype = $DB->get_field('question', 'qtype', ['id' => $questionid]); 1725 if ($questiontype == 'random') { 1726 throw new coding_exception( 1727 'Adding "random" questions via quiz_add_quiz_question() is deprecated. Please use quiz_add_random_questions().' 1728 ); 1729 } 1730 1731 $trans = $DB->start_delegated_transaction(); 1732 1733 $sql = "SELECT qbe.id 1734 FROM {quiz_slots} slot 1735 JOIN {question_references} qr ON qr.itemid = slot.id 1736 JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid 1737 WHERE slot.quizid = ? 1738 AND qr.component = ? 1739 AND qr.questionarea = ? 1740 AND qr.usingcontextid = ?"; 1741 1742 $questionslots = $DB->get_records_sql($sql, [$quiz->id, 'mod_quiz', 'slot', 1743 context_module::instance($quiz->cmid)->id]); 1744 1745 $currententry = get_question_bank_entry($questionid); 1746 1747 if (array_key_exists($currententry->id, $questionslots)) { 1748 $trans->allow_commit(); 1749 return false; 1750 } 1751 1752 $sql = "SELECT slot.slot, slot.page, slot.id 1753 FROM {quiz_slots} slot 1754 WHERE slot.quizid = ? 1755 ORDER BY slot.slot"; 1756 1757 $slots = $DB->get_records_sql($sql, [$quiz->id]); 1758 1759 $maxpage = 1; 1760 $numonlastpage = 0; 1761 foreach ($slots as $slot) { 1762 if ($slot->page > $maxpage) { 1763 $maxpage = $slot->page; 1764 $numonlastpage = 1; 1765 } else { 1766 $numonlastpage += 1; 1767 } 1768 } 1769 1770 // Add the new instance. 1771 $slot = new stdClass(); 1772 $slot->quizid = $quiz->id; 1773 1774 if ($maxmark !== null) { 1775 $slot->maxmark = $maxmark; 1776 } else { 1777 $slot->maxmark = $DB->get_field('question', 'defaultmark', ['id' => $questionid]); 1778 } 1779 1780 if (is_int($page) && $page >= 1) { 1781 // Adding on a given page. 1782 $lastslotbefore = 0; 1783 foreach (array_reverse($slots) as $otherslot) { 1784 if ($otherslot->page > $page) { 1785 $DB->set_field('quiz_slots', 'slot', $otherslot->slot + 1, ['id' => $otherslot->id]); 1786 } else { 1787 $lastslotbefore = $otherslot->slot; 1788 break; 1789 } 1790 } 1791 $slot->slot = $lastslotbefore + 1; 1792 $slot->page = min($page, $maxpage + 1); 1793 1794 quiz_update_section_firstslots($quiz->id, 1, max($lastslotbefore, 1)); 1795 1796 } else { 1797 $lastslot = end($slots); 1798 if ($lastslot) { 1799 $slot->slot = $lastslot->slot + 1; 1800 } else { 1801 $slot->slot = 1; 1802 } 1803 if ($quiz->questionsperpage && $numonlastpage >= $quiz->questionsperpage) { 1804 $slot->page = $maxpage + 1; 1805 } else { 1806 $slot->page = $maxpage; 1807 } 1808 } 1809 1810 $slotid = $DB->insert_record('quiz_slots', $slot); 1811 1812 // Update or insert record in question_reference table. 1813 $sql = "SELECT DISTINCT qr.id, qr.itemid 1814 FROM {question} q 1815 JOIN {question_versions} qv ON q.id = qv.questionid 1816 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid 1817 JOIN {question_references} qr ON qbe.id = qr.questionbankentryid AND qr.version = qv.version 1818 JOIN {quiz_slots} qs ON qs.id = qr.itemid 1819 WHERE q.id = ? 1820 AND qs.id = ? 1821 AND qr.component = ? 1822 AND qr.questionarea = ?"; 1823 $qreferenceitem = $DB->get_record_sql($sql, [$questionid, $slotid, 'mod_quiz', 'slot']); 1824 1825 if (!$qreferenceitem) { 1826 // Create a new reference record for questions created already. 1827 $questionreferences = new stdClass(); 1828 $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id; 1829 $questionreferences->component = 'mod_quiz'; 1830 $questionreferences->questionarea = 'slot'; 1831 $questionreferences->itemid = $slotid; 1832 $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id; 1833 $questionreferences->version = null; // Always latest. 1834 $DB->insert_record('question_references', $questionreferences); 1835 1836 } else if ($qreferenceitem->itemid === 0 || $qreferenceitem->itemid === null) { 1837 $questionreferences = new stdClass(); 1838 $questionreferences->id = $qreferenceitem->id; 1839 $questionreferences->itemid = $slotid; 1840 $DB->update_record('question_references', $questionreferences); 1841 } else { 1842 // If the reference record exits for another quiz. 1843 $questionreferences = new stdClass(); 1844 $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id; 1845 $questionreferences->component = 'mod_quiz'; 1846 $questionreferences->questionarea = 'slot'; 1847 $questionreferences->itemid = $slotid; 1848 $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id; 1849 $questionreferences->version = null; // Always latest. 1850 $DB->insert_record('question_references', $questionreferences); 1851 } 1852 1853 $trans->allow_commit(); 1854 1855 // Log slot created event. 1856 $cm = get_coursemodule_from_instance('quiz', $quiz->id); 1857 $event = \mod_quiz\event\slot_created::create([ 1858 'context' => context_module::instance($cm->id), 1859 'objectid' => $slotid, 1860 'other' => [ 1861 'quizid' => $quiz->id, 1862 'slotnumber' => $slot->slot, 1863 'page' => $slot->page 1864 ] 1865 ]); 1866 $event->trigger(); 1867 } 1868 1869 /** 1870 * Move all the section headings in a certain slot range by a certain offset. 1871 * 1872 * @param int $quizid the id of a quiz 1873 * @param int $direction amount to adjust section heading positions. Normally +1 or -1. 1874 * @param int $afterslot adjust headings that start after this slot. 1875 * @param int|null $beforeslot optionally, only adjust headings before this slot. 1876 */ 1877 function quiz_update_section_firstslots($quizid, $direction, $afterslot, $beforeslot = null) { 1878 global $DB; 1879 $where = 'quizid = ? AND firstslot > ?'; 1880 $params = [$direction, $quizid, $afterslot]; 1881 if ($beforeslot) { 1882 $where .= ' AND firstslot < ?'; 1883 $params[] = $beforeslot; 1884 } 1885 $firstslotschanges = $DB->get_records_select_menu('quiz_sections', 1886 $where, $params, '', 'firstslot, firstslot + ?'); 1887 update_field_with_unique_index('quiz_sections', 'firstslot', $firstslotschanges, ['quizid' => $quizid]); 1888 } 1889 1890 /** 1891 * Add a random question to the quiz at a given point. 1892 * @param stdClass $quiz the quiz settings. 1893 * @param int $addonpage the page on which to add the question. 1894 * @param int $categoryid the question category to add the question from. 1895 * @param int $number the number of random questions to add. 1896 * @param bool $includesubcategories whether to include questoins from subcategories. 1897 * @param int[] $tagids Array of tagids. The question that will be picked randomly should be tagged with all these tags. 1898 */ 1899 function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number, 1900 $includesubcategories, $tagids = []) { 1901 global $DB; 1902 1903 $category = $DB->get_record('question_categories', ['id' => $categoryid]); 1904 if (!$category) { 1905 new moodle_exception('invalidcategoryid'); 1906 } 1907 1908 $catcontext = context::instance_by_id($category->contextid); 1909 require_capability('moodle/question:useall', $catcontext); 1910 1911 // Tags for filter condition. 1912 $tags = \core_tag_tag::get_bulk($tagids, 'id, name'); 1913 $tagstrings = []; 1914 foreach ($tags as $tag) { 1915 $tagstrings[] = "{$tag->id},{$tag->name}"; 1916 } 1917 // Create the selected number of random questions. 1918 for ($i = 0; $i < $number; $i++) { 1919 // Set the filter conditions. 1920 $filtercondition = new stdClass(); 1921 $filtercondition->questioncategoryid = $categoryid; 1922 $filtercondition->includingsubcategories = $includesubcategories ? 1 : 0; 1923 if (!empty($tagstrings)) { 1924 $filtercondition->tags = $tagstrings; 1925 } 1926 1927 if (!isset($quiz->cmid)) { 1928 $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); 1929 $quiz->cmid = $cm->id; 1930 } 1931 1932 // Slot data. 1933 $randomslotdata = new stdClass(); 1934 $randomslotdata->quizid = $quiz->id; 1935 $randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id; 1936 $randomslotdata->questionscontextid = $category->contextid; 1937 $randomslotdata->maxmark = 1; 1938 1939 $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); 1940 $randomslot->set_quiz($quiz); 1941 $randomslot->set_filter_condition($filtercondition); 1942 $randomslot->insert($addonpage); 1943 } 1944 } 1945 1946 /** 1947 * Mark the activity completed (if required) and trigger the course_module_viewed event. 1948 * 1949 * @param stdClass $quiz quiz object 1950 * @param stdClass $course course object 1951 * @param stdClass $cm course module object 1952 * @param stdClass $context context object 1953 * @since Moodle 3.1 1954 */ 1955 function quiz_view($quiz, $course, $cm, $context) { 1956 1957 $params = [ 1958 'objectid' => $quiz->id, 1959 'context' => $context 1960 ]; 1961 1962 $event = \mod_quiz\event\course_module_viewed::create($params); 1963 $event->add_record_snapshot('quiz', $quiz); 1964 $event->trigger(); 1965 1966 // Completion. 1967 $completion = new completion_info($course); 1968 $completion->set_module_viewed($cm); 1969 } 1970 1971 /** 1972 * Validate permissions for creating a new attempt and start a new preview attempt if required. 1973 * 1974 * @param quiz_settings $quizobj quiz object 1975 * @param access_manager $accessmanager quiz access manager 1976 * @param bool $forcenew whether was required to start a new preview attempt 1977 * @param int $page page to jump to in the attempt 1978 * @param bool $redirect whether to redirect or throw exceptions (for web or ws usage) 1979 * @return array an array containing the attempt information, access error messages and the page to jump to in the attempt 1980 * @since Moodle 3.1 1981 */ 1982 function quiz_validate_new_attempt(quiz_settings $quizobj, access_manager $accessmanager, $forcenew, $page, $redirect) { 1983 global $DB, $USER; 1984 $timenow = time(); 1985 1986 if ($quizobj->is_preview_user() && $forcenew) { 1987 $accessmanager->current_attempt_finished(); 1988 } 1989 1990 // Check capabilities. 1991 if (!$quizobj->is_preview_user()) { 1992 $quizobj->require_capability('mod/quiz:attempt'); 1993 } 1994 1995 // Check to see if a new preview was requested. 1996 if ($quizobj->is_preview_user() && $forcenew) { 1997 // To force the creation of a new preview, we mark the current attempt (if any) 1998 // as abandoned. It will then automatically be deleted below. 1999 $DB->set_field('quiz_attempts', 'state', quiz_attempt::ABANDONED, 2000 ['quiz' => $quizobj->get_quizid(), 'userid' => $USER->id]); 2001 } 2002 2003 // Look for an existing attempt. 2004 $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $USER->id, 'all', true); 2005 $lastattempt = end($attempts); 2006 2007 $attemptnumber = null; 2008 // If an in-progress attempt exists, check password then redirect to it. 2009 if ($lastattempt && ($lastattempt->state == quiz_attempt::IN_PROGRESS || 2010 $lastattempt->state == quiz_attempt::OVERDUE)) { 2011 $currentattemptid = $lastattempt->id; 2012 $messages = $accessmanager->prevent_access(); 2013 2014 // If the attempt is now overdue, deal with that. 2015 $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow, true); 2016 2017 // And, if the attempt is now no longer in progress, redirect to the appropriate place. 2018 if ($lastattempt->state == quiz_attempt::ABANDONED || $lastattempt->state == quiz_attempt::FINISHED) { 2019 if ($redirect) { 2020 redirect($quizobj->review_url($lastattempt->id)); 2021 } else { 2022 throw new moodle_exception('attemptalreadyclosed', 'quiz', $quizobj->view_url()); 2023 } 2024 } 2025 2026 // If the page number was not explicitly in the URL, go to the current page. 2027 if ($page == -1) { 2028 $page = $lastattempt->currentpage; 2029 } 2030 2031 } else { 2032 while ($lastattempt && $lastattempt->preview) { 2033 $lastattempt = array_pop($attempts); 2034 } 2035 2036 // Get number for the next or unfinished attempt. 2037 if ($lastattempt) { 2038 $attemptnumber = $lastattempt->attempt + 1; 2039 } else { 2040 $lastattempt = false; 2041 $attemptnumber = 1; 2042 } 2043 $currentattemptid = null; 2044 2045 $messages = $accessmanager->prevent_access() + 2046 $accessmanager->prevent_new_attempt(count($attempts), $lastattempt); 2047 2048 if ($page == -1) { 2049 $page = 0; 2050 } 2051 } 2052 return [$currentattemptid, $attemptnumber, $lastattempt, $messages, $page]; 2053 } 2054 2055 /** 2056 * Prepare and start a new attempt deleting the previous preview attempts. 2057 * 2058 * @param quiz_settings $quizobj quiz object 2059 * @param int $attemptnumber the attempt number 2060 * @param stdClass $lastattempt last attempt object 2061 * @param bool $offlineattempt whether is an offline attempt or not 2062 * @param array $forcedrandomquestions slot number => question id. Used for random questions, 2063 * to force the choice of a particular actual question. Intended for testing purposes only. 2064 * @param array $forcedvariants slot number => variant. Used for questions with variants, 2065 * to force the choice of a particular variant. Intended for testing purposes only. 2066 * @param int $userid Specific user id to create an attempt for that user, null for current logged in user 2067 * @return stdClass the new attempt 2068 * @since Moodle 3.1 2069 */ 2070 function quiz_prepare_and_start_new_attempt(quiz_settings $quizobj, $attemptnumber, $lastattempt, 2071 $offlineattempt = false, $forcedrandomquestions = [], $forcedvariants = [], $userid = null) { 2072 global $DB, $USER; 2073 2074 if ($userid === null) { 2075 $userid = $USER->id; 2076 $ispreviewuser = $quizobj->is_preview_user(); 2077 } else { 2078 $ispreviewuser = has_capability('mod/quiz:preview', $quizobj->get_context(), $userid); 2079 } 2080 // Delete any previous preview attempts belonging to this user. 2081 quiz_delete_previews($quizobj->get_quiz(), $userid); 2082 2083 $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); 2084 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); 2085 2086 // Create the new attempt and initialize the question sessions 2087 $timenow = time(); // Update time now, in case the server is running really slowly. 2088 $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $ispreviewuser, $userid); 2089 2090 if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) { 2091 $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, 2092 $forcedrandomquestions, $forcedvariants); 2093 } else { 2094 $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt); 2095 } 2096 2097 $transaction = $DB->start_delegated_transaction(); 2098 2099 // Init the timemodifiedoffline for offline attempts. 2100 if ($offlineattempt) { 2101 $attempt->timemodifiedoffline = $attempt->timemodified; 2102 } 2103 $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt); 2104 2105 $transaction->allow_commit(); 2106 2107 return $attempt; 2108 } 2109 2110 /** 2111 * Check if the given calendar_event is either a user or group override 2112 * event for quiz. 2113 * 2114 * @param calendar_event $event The calendar event to check 2115 * @return bool 2116 */ 2117 function quiz_is_overriden_calendar_event(\calendar_event $event) { 2118 global $DB; 2119 2120 if (!isset($event->modulename)) { 2121 return false; 2122 } 2123 2124 if ($event->modulename != 'quiz') { 2125 return false; 2126 } 2127 2128 if (!isset($event->instance)) { 2129 return false; 2130 } 2131 2132 if (!isset($event->userid) && !isset($event->groupid)) { 2133 return false; 2134 } 2135 2136 $overrideparams = [ 2137 'quiz' => $event->instance 2138 ]; 2139 2140 if (isset($event->groupid)) { 2141 $overrideparams['groupid'] = $event->groupid; 2142 } else if (isset($event->userid)) { 2143 $overrideparams['userid'] = $event->userid; 2144 } 2145 2146 return $DB->record_exists('quiz_overrides', $overrideparams); 2147 } 2148 2149 /** 2150 * Get quiz attempt and handling error. 2151 * 2152 * @param int $attemptid the id of the current attempt. 2153 * @param int|null $cmid the course_module id for this quiz. 2154 * @return quiz_attempt all the data about the quiz attempt. 2155 */ 2156 function quiz_create_attempt_handling_errors($attemptid, $cmid = null) { 2157 try { 2158 $attempobj = quiz_attempt::create($attemptid); 2159 } catch (moodle_exception $e) { 2160 if (!empty($cmid)) { 2161 list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'quiz'); 2162 $continuelink = new moodle_url('/mod/quiz/view.php', ['id' => $cmid]); 2163 $context = context_module::instance($cm->id); 2164 if (has_capability('mod/quiz:preview', $context)) { 2165 throw new moodle_exception('attempterrorcontentchange', 'quiz', $continuelink); 2166 } else { 2167 throw new moodle_exception('attempterrorcontentchangeforuser', 'quiz', $continuelink); 2168 } 2169 } else { 2170 throw new moodle_exception('attempterrorinvalid', 'quiz'); 2171 } 2172 } 2173 if (!empty($cmid) && $attempobj->get_cmid() != $cmid) { 2174 throw new moodle_exception('invalidcoursemodule'); 2175 } else { 2176 return $attempobj; 2177 } 2178 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body