See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * 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->dirroot . '/mod/quiz/accessmanager.php'); 35 require_once($CFG->dirroot . '/mod/quiz/accessmanager_form.php'); 36 require_once($CFG->dirroot . '/mod/quiz/renderer.php'); 37 require_once($CFG->dirroot . '/mod/quiz/attemptlib.php'); 38 require_once($CFG->libdir . '/completionlib.php'); 39 require_once($CFG->libdir . '/filelib.php'); 40 require_once($CFG->libdir . '/questionlib.php'); 41 42 use mod_quiz\question\bank\qbank_helper; 43 use qbank_previewquestion\question_preview_options; 44 45 /** 46 * @var int We show the countdown timer if there is less than this amount of time left before the 47 * the quiz close date. (1 hour) 48 */ 49 define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600'); 50 51 /** 52 * @var int If there are fewer than this many seconds left when the student submits 53 * a page of the quiz, then do not take them to the next page of the quiz. Instead 54 * close the quiz immediately. 55 */ 56 define('QUIZ_MIN_TIME_TO_CONTINUE', '2'); 57 58 /** 59 * @var int We show no image when user selects No image from dropdown menu in quiz settings. 60 */ 61 define('QUIZ_SHOWIMAGE_NONE', 0); 62 63 /** 64 * @var int We show small image when user selects small image from dropdown menu in quiz settings. 65 */ 66 define('QUIZ_SHOWIMAGE_SMALL', 1); 67 68 /** 69 * @var int We show Large image when user selects Large image from dropdown menu in quiz settings. 70 */ 71 define('QUIZ_SHOWIMAGE_LARGE', 2); 72 73 74 // Functions related to attempts /////////////////////////////////////////////// 75 76 /** 77 * Creates an object to represent a new attempt at a quiz 78 * 79 * Creates an attempt object to represent an attempt at the quiz by the current 80 * user starting at the current time. The ->id field is not set. The object is 81 * NOT written to the database. 82 * 83 * @param object $quizobj the quiz object to create an attempt for. 84 * @param int $attemptnumber the sequence number for the attempt. 85 * @param stdClass|null $lastattempt the previous attempt by this user, if any. Only needed 86 * if $attemptnumber > 1 and $quiz->attemptonlast is true. 87 * @param int $timenow the time the attempt was started at. 88 * @param bool $ispreview whether this new attempt is a preview. 89 * @param int $userid the id of the user attempting this quiz. 90 * 91 * @return object the newly created attempt object. 92 */ 93 function quiz_create_attempt(quiz $quizobj, $attemptnumber, $lastattempt, $timenow, $ispreview = false, $userid = null) { 94 global $USER; 95 96 if ($userid === null) { 97 $userid = $USER->id; 98 } 99 100 $quiz = $quizobj->get_quiz(); 101 if ($quiz->sumgrades < 0.000005 && $quiz->grade > 0.000005) { 102 throw new moodle_exception('cannotstartgradesmismatch', 'quiz', 103 new moodle_url('/mod/quiz/view.php', array('q' => $quiz->id)), 104 array('grade' => quiz_format_grade($quiz, $quiz->grade))); 105 } 106 107 if ($attemptnumber == 1 || !$quiz->attemptonlast) { 108 // We are not building on last attempt so create a new attempt. 109 $attempt = new stdClass(); 110 $attempt->quiz = $quiz->id; 111 $attempt->userid = $userid; 112 $attempt->preview = 0; 113 $attempt->layout = ''; 114 } else { 115 // Build on last attempt. 116 if (empty($lastattempt)) { 117 throw new \moodle_exception('cannotfindprevattempt', 'quiz'); 118 } 119 $attempt = $lastattempt; 120 } 121 122 $attempt->attempt = $attemptnumber; 123 $attempt->timestart = $timenow; 124 $attempt->timefinish = 0; 125 $attempt->timemodified = $timenow; 126 $attempt->timemodifiedoffline = 0; 127 $attempt->state = quiz_attempt::IN_PROGRESS; 128 $attempt->currentpage = 0; 129 $attempt->sumgrades = null; 130 $attempt->gradednotificationsenttime = null; 131 132 // If this is a preview, mark it as such. 133 if ($ispreview) { 134 $attempt->preview = 1; 135 } 136 137 $timeclose = $quizobj->get_access_manager($timenow)->get_end_time($attempt); 138 if ($timeclose === false || $ispreview) { 139 $attempt->timecheckstate = null; 140 } else { 141 $attempt->timecheckstate = $timeclose; 142 } 143 144 return $attempt; 145 } 146 /** 147 * Start a normal, new, quiz attempt. 148 * 149 * @param quiz $quizobj the quiz object to start an attempt for. 150 * @param question_usage_by_activity $quba 151 * @param object $attempt 152 * @param integer $attemptnumber starting from 1 153 * @param integer $timenow the attempt start time 154 * @param array $questionids slot number => question id. Used for random questions, to force the choice 155 * of a particular actual question. Intended for testing purposes only. 156 * @param array $forcedvariantsbyslot slot number => variant. Used for questions with variants, 157 * to force the choice of a particular variant. Intended for testing 158 * purposes only. 159 * @throws moodle_exception 160 * @return object modified attempt object 161 */ 162 function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, 163 $questionids = array(), $forcedvariantsbyslot = array()) { 164 165 // Usages for this user's previous quiz attempts. 166 $qubaids = new \mod_quiz\question\qubaids_for_users_attempts( 167 $quizobj->get_quizid(), $attempt->userid); 168 169 // Fully load all the questions in this quiz. 170 $quizobj->preload_questions(); 171 $quizobj->load_questions(); 172 173 // First load all the non-random questions. 174 $randomfound = false; 175 $slot = 0; 176 $questions = array(); 177 $maxmark = array(); 178 $page = array(); 179 foreach ($quizobj->get_questions() as $questiondata) { 180 $slot += 1; 181 $maxmark[$slot] = $questiondata->maxmark; 182 $page[$slot] = $questiondata->page; 183 if ($questiondata->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT) { 184 throw new moodle_exception('questiondraftonly', 'mod_quiz', '', $questiondata->name); 185 } 186 if ($questiondata->qtype == 'random') { 187 $randomfound = true; 188 continue; 189 } 190 if (!$quizobj->get_quiz()->shuffleanswers) { 191 $questiondata->options->shuffleanswers = false; 192 } 193 $questions[$slot] = question_bank::make_question($questiondata); 194 } 195 196 // Then find a question to go in place of each random question. 197 if ($randomfound) { 198 $slot = 0; 199 $usedquestionids = array(); 200 foreach ($questions as $question) { 201 if ($question->id && isset($usedquestions[$question->id])) { 202 $usedquestionids[$question->id] += 1; 203 } else { 204 $usedquestionids[$question->id] = 1; 205 } 206 } 207 $randomloader = new \core_question\local\bank\random_question_loader($qubaids, $usedquestionids); 208 209 foreach ($quizobj->get_questions() as $questiondata) { 210 $slot += 1; 211 if ($questiondata->qtype != 'random') { 212 continue; 213 } 214 215 $tagids = qbank_helper::get_tag_ids_for_slot($questiondata); 216 217 // Deal with fixed random choices for testing. 218 if (isset($questionids[$quba->next_slot_number()])) { 219 if ($randomloader->is_question_available($questiondata->category, 220 (bool) $questiondata->questiontext, $questionids[$quba->next_slot_number()], $tagids)) { 221 $questions[$slot] = question_bank::load_question( 222 $questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers); 223 continue; 224 } else { 225 throw new coding_exception('Forced question id not available.'); 226 } 227 } 228 229 // Normal case, pick one at random. 230 $questionid = $randomloader->get_next_question_id($questiondata->category, 231 $questiondata->randomrecurse, $tagids); 232 if ($questionid === null) { 233 throw new moodle_exception('notenoughrandomquestions', 'quiz', 234 $quizobj->view_url(), $questiondata); 235 } 236 237 $questions[$slot] = question_bank::load_question($questionid, 238 $quizobj->get_quiz()->shuffleanswers); 239 } 240 } 241 242 // Finally add them all to the usage. 243 ksort($questions); 244 foreach ($questions as $slot => $question) { 245 $newslot = $quba->add_question($question, $maxmark[$slot]); 246 if ($newslot != $slot) { 247 throw new coding_exception('Slot numbers have got confused.'); 248 } 249 } 250 251 // Start all the questions. 252 $variantstrategy = new core_question\engine\variants\least_used_strategy($quba, $qubaids); 253 254 if (!empty($forcedvariantsbyslot)) { 255 $forcedvariantsbyseed = question_variant_forced_choices_selection_strategy::prepare_forced_choices_array( 256 $forcedvariantsbyslot, $quba); 257 $variantstrategy = new question_variant_forced_choices_selection_strategy( 258 $forcedvariantsbyseed, $variantstrategy); 259 } 260 261 $quba->start_all_questions($variantstrategy, $timenow, $attempt->userid); 262 263 // Work out the attempt layout. 264 $sections = $quizobj->get_sections(); 265 foreach ($sections as $i => $section) { 266 if (isset($sections[$i + 1])) { 267 $sections[$i]->lastslot = $sections[$i + 1]->firstslot - 1; 268 } else { 269 $sections[$i]->lastslot = count($questions); 270 } 271 } 272 273 $layout = array(); 274 foreach ($sections as $section) { 275 if ($section->shufflequestions) { 276 $questionsinthissection = array(); 277 for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { 278 $questionsinthissection[] = $slot; 279 } 280 shuffle($questionsinthissection); 281 $questionsonthispage = 0; 282 foreach ($questionsinthissection as $slot) { 283 if ($questionsonthispage && $questionsonthispage == $quizobj->get_quiz()->questionsperpage) { 284 $layout[] = 0; 285 $questionsonthispage = 0; 286 } 287 $layout[] = $slot; 288 $questionsonthispage += 1; 289 } 290 291 } else { 292 $currentpage = $page[$section->firstslot]; 293 for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { 294 if ($currentpage !== null && $page[$slot] != $currentpage) { 295 $layout[] = 0; 296 } 297 $layout[] = $slot; 298 $currentpage = $page[$slot]; 299 } 300 } 301 302 // Each section ends with a page break. 303 $layout[] = 0; 304 } 305 $attempt->layout = implode(',', $layout); 306 307 return $attempt; 308 } 309 310 /** 311 * Start a subsequent new attempt, in each attempt builds on last mode. 312 * 313 * @param question_usage_by_activity $quba this question usage 314 * @param object $attempt this attempt 315 * @param object $lastattempt last attempt 316 * @return object modified attempt object 317 * 318 */ 319 function quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt) { 320 $oldquba = question_engine::load_questions_usage_by_activity($lastattempt->uniqueid); 321 322 $oldnumberstonew = array(); 323 foreach ($oldquba->get_attempt_iterator() as $oldslot => $oldqa) { 324 $question = $oldqa->get_question(false); 325 if ($question->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT) { 326 throw new moodle_exception('questiondraftonly', 'mod_quiz', '', $question->name); 327 } 328 $newslot = $quba->add_question($question, $oldqa->get_max_mark()); 329 330 $quba->start_question_based_on($newslot, $oldqa); 331 332 $oldnumberstonew[$oldslot] = $newslot; 333 } 334 335 // Update attempt layout. 336 $newlayout = array(); 337 foreach (explode(',', $lastattempt->layout) as $oldslot) { 338 if ($oldslot != 0) { 339 $newlayout[] = $oldnumberstonew[$oldslot]; 340 } else { 341 $newlayout[] = 0; 342 } 343 } 344 $attempt->layout = implode(',', $newlayout); 345 return $attempt; 346 } 347 348 /** 349 * The save started question usage and quiz attempt in db and log the started attempt. 350 * 351 * @param quiz $quizobj 352 * @param question_usage_by_activity $quba 353 * @param object $attempt 354 * @return object attempt object with uniqueid and id set. 355 */ 356 function quiz_attempt_save_started($quizobj, $quba, $attempt) { 357 global $DB; 358 // Save the attempt in the database. 359 question_engine::save_questions_usage_by_activity($quba); 360 $attempt->uniqueid = $quba->get_id(); 361 $attempt->id = $DB->insert_record('quiz_attempts', $attempt); 362 363 // Params used by the events below. 364 $params = array( 365 'objectid' => $attempt->id, 366 'relateduserid' => $attempt->userid, 367 'courseid' => $quizobj->get_courseid(), 368 'context' => $quizobj->get_context() 369 ); 370 // Decide which event we are using. 371 if ($attempt->preview) { 372 $params['other'] = array( 373 'quizid' => $quizobj->get_quizid() 374 ); 375 $event = \mod_quiz\event\attempt_preview_started::create($params); 376 } else { 377 $event = \mod_quiz\event\attempt_started::create($params); 378 379 } 380 381 // Trigger the event. 382 $event->add_record_snapshot('quiz', $quizobj->get_quiz()); 383 $event->add_record_snapshot('quiz_attempts', $attempt); 384 $event->trigger(); 385 386 return $attempt; 387 } 388 389 /** 390 * Returns an unfinished attempt (if there is one) for the given 391 * user on the given quiz. This function does not return preview attempts. 392 * 393 * @param int $quizid the id of the quiz. 394 * @param int $userid the id of the user. 395 * 396 * @return mixed the unfinished attempt if there is one, false if not. 397 */ 398 function quiz_get_user_attempt_unfinished($quizid, $userid) { 399 $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true); 400 if ($attempts) { 401 return array_shift($attempts); 402 } else { 403 return false; 404 } 405 } 406 407 /** 408 * Delete a quiz attempt. 409 * @param mixed $attempt an integer attempt id or an attempt object 410 * (row of the quiz_attempts table). 411 * @param object $quiz the quiz object. 412 */ 413 function quiz_delete_attempt($attempt, $quiz) { 414 global $DB; 415 if (is_numeric($attempt)) { 416 if (!$attempt = $DB->get_record('quiz_attempts', array('id' => $attempt))) { 417 return; 418 } 419 } 420 421 if ($attempt->quiz != $quiz->id) { 422 debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " . 423 "but was passed quiz $quiz->id."); 424 return; 425 } 426 427 if (!isset($quiz->cmid)) { 428 $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); 429 $quiz->cmid = $cm->id; 430 } 431 432 question_engine::delete_questions_usage_by_activity($attempt->uniqueid); 433 $DB->delete_records('quiz_attempts', array('id' => $attempt->id)); 434 435 // Log the deletion of the attempt if not a preview. 436 if (!$attempt->preview) { 437 $params = array( 438 'objectid' => $attempt->id, 439 'relateduserid' => $attempt->userid, 440 'context' => context_module::instance($quiz->cmid), 441 'other' => array( 442 'quizid' => $quiz->id 443 ) 444 ); 445 $event = \mod_quiz\event\attempt_deleted::create($params); 446 $event->add_record_snapshot('quiz_attempts', $attempt); 447 $event->trigger(); 448 449 $callbackclasses = \core_component::get_plugin_list_with_class('quiz', 'quiz_attempt_deleted'); 450 foreach ($callbackclasses as $callbackclass) { 451 component_class_callback($callbackclass, 'callback', [$quiz->id]); 452 } 453 } 454 455 // Search quiz_attempts for other instances by this user. 456 // If none, then delete record for this quiz, this user from quiz_grades 457 // else recalculate best grade. 458 $userid = $attempt->userid; 459 if (!$DB->record_exists('quiz_attempts', array('userid' => $userid, 'quiz' => $quiz->id))) { 460 $DB->delete_records('quiz_grades', array('userid' => $userid, 'quiz' => $quiz->id)); 461 } else { 462 quiz_save_best_grade($quiz, $userid); 463 } 464 465 quiz_update_grades($quiz, $userid); 466 } 467 468 /** 469 * Delete all the preview attempts at a quiz, or possibly all the attempts belonging 470 * to one user. 471 * @param object $quiz the quiz object. 472 * @param int $userid (optional) if given, only delete the previews belonging to this user. 473 */ 474 function quiz_delete_previews($quiz, $userid = null) { 475 global $DB; 476 $conditions = array('quiz' => $quiz->id, 'preview' => 1); 477 if (!empty($userid)) { 478 $conditions['userid'] = $userid; 479 } 480 $previewattempts = $DB->get_records('quiz_attempts', $conditions); 481 foreach ($previewattempts as $attempt) { 482 quiz_delete_attempt($attempt, $quiz); 483 } 484 } 485 486 /** 487 * @param int $quizid The quiz id. 488 * @return bool whether this quiz has any (non-preview) attempts. 489 */ 490 function quiz_has_attempts($quizid) { 491 global $DB; 492 return $DB->record_exists('quiz_attempts', array('quiz' => $quizid, 'preview' => 0)); 493 } 494 495 // Functions to do with quiz layout and pages ////////////////////////////////// 496 497 /** 498 * Repaginate the questions in a quiz 499 * @param int $quizid the id of the quiz to repaginate. 500 * @param int $slotsperpage number of items to put on each page. 0 means unlimited. 501 */ 502 function quiz_repaginate_questions($quizid, $slotsperpage) { 503 global $DB; 504 $trans = $DB->start_delegated_transaction(); 505 506 $sections = $DB->get_records('quiz_sections', array('quizid' => $quizid), 'firstslot ASC'); 507 $firstslots = array(); 508 foreach ($sections as $section) { 509 if ((int)$section->firstslot === 1) { 510 continue; 511 } 512 $firstslots[] = $section->firstslot; 513 } 514 515 $slots = $DB->get_records('quiz_slots', array('quizid' => $quizid), 516 'slot'); 517 $currentpage = 1; 518 $slotsonthispage = 0; 519 foreach ($slots as $slot) { 520 if (($firstslots && in_array($slot->slot, $firstslots)) || 521 ($slotsonthispage && $slotsonthispage == $slotsperpage)) { 522 $currentpage += 1; 523 $slotsonthispage = 0; 524 } 525 if ($slot->page != $currentpage) { 526 $DB->set_field('quiz_slots', 'page', $currentpage, array('id' => $slot->id)); 527 } 528 $slotsonthispage += 1; 529 } 530 531 $trans->allow_commit(); 532 533 // Log quiz re-paginated event. 534 $cm = get_coursemodule_from_instance('quiz', $quizid); 535 $event = \mod_quiz\event\quiz_repaginated::create([ 536 'context' => \context_module::instance($cm->id), 537 'objectid' => $quizid, 538 'other' => [ 539 'slotsperpage' => $slotsperpage 540 ] 541 ]); 542 $event->trigger(); 543 544 } 545 546 // Functions to do with quiz grades //////////////////////////////////////////// 547 548 /** 549 * Convert the raw grade stored in $attempt into a grade out of the maximum 550 * grade for this quiz. 551 * 552 * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades 553 * @param object $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used. 554 * @param bool|string $format whether to format the results for display 555 * or 'question' to format a question grade (different number of decimal places. 556 * @return float|string the rescaled grade, or null/the lang string 'notyetgraded' 557 * if the $grade is null. 558 */ 559 function quiz_rescale_grade($rawgrade, $quiz, $format = true) { 560 if (is_null($rawgrade)) { 561 $grade = null; 562 } else if ($quiz->sumgrades >= 0.000005) { 563 $grade = $rawgrade * $quiz->grade / $quiz->sumgrades; 564 } else { 565 $grade = 0; 566 } 567 if ($format === 'question') { 568 $grade = quiz_format_question_grade($quiz, $grade); 569 } else if ($format) { 570 $grade = quiz_format_grade($quiz, $grade); 571 } 572 return $grade; 573 } 574 575 /** 576 * Get the feedback object for this grade on this quiz. 577 * 578 * @param float $grade a grade on this quiz. 579 * @param object $quiz the quiz settings. 580 * @return false|stdClass the record object or false if there is not feedback for the given grade 581 * @since Moodle 3.1 582 */ 583 function quiz_feedback_record_for_grade($grade, $quiz) { 584 global $DB; 585 586 // With CBM etc, it is possible to get -ve grades, which would then not match 587 // any feedback. Therefore, we replace -ve grades with 0. 588 $grade = max($grade, 0); 589 590 $feedback = $DB->get_record_select('quiz_feedback', 591 'quizid = ? AND mingrade <= ? AND ? < maxgrade', array($quiz->id, $grade, $grade)); 592 593 return $feedback; 594 } 595 596 /** 597 * Get the feedback text that should be show to a student who 598 * got this grade on this quiz. The feedback is processed ready for diplay. 599 * 600 * @param float $grade a grade on this quiz. 601 * @param object $quiz the quiz settings. 602 * @param object $context the quiz context. 603 * @return string the comment that corresponds to this grade (empty string if there is not one. 604 */ 605 function quiz_feedback_for_grade($grade, $quiz, $context) { 606 607 if (is_null($grade)) { 608 return ''; 609 } 610 611 $feedback = quiz_feedback_record_for_grade($grade, $quiz); 612 613 if (empty($feedback->feedbacktext)) { 614 return ''; 615 } 616 617 // Clean the text, ready for display. 618 $formatoptions = new stdClass(); 619 $formatoptions->noclean = true; 620 $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext, 'pluginfile.php', 621 $context->id, 'mod_quiz', 'feedback', $feedback->id); 622 $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions); 623 624 return $feedbacktext; 625 } 626 627 /** 628 * @param object $quiz the quiz database row. 629 * @return bool Whether this quiz has any non-blank feedback text. 630 */ 631 function quiz_has_feedback($quiz) { 632 global $DB; 633 static $cache = array(); 634 if (!array_key_exists($quiz->id, $cache)) { 635 $cache[$quiz->id] = quiz_has_grades($quiz) && 636 $DB->record_exists_select('quiz_feedback', "quizid = ? AND " . 637 $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true), 638 array($quiz->id)); 639 } 640 return $cache[$quiz->id]; 641 } 642 643 /** 644 * Update the sumgrades field of the quiz. This needs to be called whenever 645 * the grading structure of the quiz is changed. For example if a question is 646 * added or removed, or a question weight is changed. 647 * 648 * You should call {@link quiz_delete_previews()} before you call this function. 649 * 650 * @param object $quiz a quiz. 651 */ 652 function quiz_update_sumgrades($quiz) { 653 global $DB; 654 655 $sql = 'UPDATE {quiz} 656 SET sumgrades = COALESCE(( 657 SELECT SUM(maxmark) 658 FROM {quiz_slots} 659 WHERE quizid = {quiz}.id 660 ), 0) 661 WHERE id = ?'; 662 $DB->execute($sql, array($quiz->id)); 663 $quiz->sumgrades = $DB->get_field('quiz', 'sumgrades', array('id' => $quiz->id)); 664 665 if ($quiz->sumgrades < 0.000005 && quiz_has_attempts($quiz->id)) { 666 // If the quiz has been attempted, and the sumgrades has been 667 // set to 0, then we must also set the maximum possible grade to 0, or 668 // we will get a divide by zero error. 669 quiz_set_grade(0, $quiz); 670 } 671 672 $callbackclasses = \core_component::get_plugin_list_with_class('quiz', 'quiz_structure_modified'); 673 foreach ($callbackclasses as $callbackclass) { 674 component_class_callback($callbackclass, 'callback', [$quiz->id]); 675 } 676 } 677 678 /** 679 * Update the sumgrades field of the attempts at a quiz. 680 * 681 * @param object $quiz a quiz. 682 */ 683 function quiz_update_all_attempt_sumgrades($quiz) { 684 global $DB; 685 $dm = new question_engine_data_mapper(); 686 $timenow = time(); 687 688 $sql = "UPDATE {quiz_attempts} 689 SET 690 timemodified = :timenow, 691 sumgrades = ( 692 {$dm->sum_usage_marks_subquery('uniqueid')} 693 ) 694 WHERE quiz = :quizid AND state = :finishedstate"; 695 $DB->execute($sql, array('timenow' => $timenow, 'quizid' => $quiz->id, 696 'finishedstate' => quiz_attempt::FINISHED)); 697 } 698 699 /** 700 * The quiz grade is the maximum that student's results are marked out of. When it 701 * changes, the corresponding data in quiz_grades and quiz_feedback needs to be 702 * rescaled. After calling this function, you probably need to call 703 * quiz_update_all_attempt_sumgrades, quiz_update_all_final_grades and 704 * quiz_update_grades. 705 * 706 * @param float $newgrade the new maximum grade for the quiz. 707 * @param object $quiz the quiz we are updating. Passed by reference so its 708 * grade field can be updated too. 709 * @return bool indicating success or failure. 710 */ 711 function quiz_set_grade($newgrade, $quiz) { 712 global $DB; 713 // This is potentially expensive, so only do it if necessary. 714 if (abs($quiz->grade - $newgrade) < 1e-7) { 715 // Nothing to do. 716 return true; 717 } 718 719 $oldgrade = $quiz->grade; 720 $quiz->grade = $newgrade; 721 722 // Use a transaction, so that on those databases that support it, this is safer. 723 $transaction = $DB->start_delegated_transaction(); 724 725 // Update the quiz table. 726 $DB->set_field('quiz', 'grade', $newgrade, array('id' => $quiz->instance)); 727 728 if ($oldgrade < 1) { 729 // If the old grade was zero, we cannot rescale, we have to recompute. 730 // We also recompute if the old grade was too small to avoid underflow problems. 731 quiz_update_all_final_grades($quiz); 732 733 } else { 734 // We can rescale the grades efficiently. 735 $timemodified = time(); 736 $DB->execute(" 737 UPDATE {quiz_grades} 738 SET grade = ? * grade, timemodified = ? 739 WHERE quiz = ? 740 ", array($newgrade/$oldgrade, $timemodified, $quiz->id)); 741 } 742 743 if ($oldgrade > 1e-7) { 744 // Update the quiz_feedback table. 745 $factor = $newgrade/$oldgrade; 746 $DB->execute(" 747 UPDATE {quiz_feedback} 748 SET mingrade = ? * mingrade, maxgrade = ? * maxgrade 749 WHERE quizid = ? 750 ", array($factor, $factor, $quiz->id)); 751 } 752 753 // Update grade item and send all grades to gradebook. 754 quiz_grade_item_update($quiz); 755 quiz_update_grades($quiz); 756 757 $transaction->allow_commit(); 758 759 // Log quiz grade updated event. 760 // We use $num + 0 as a trick to remove the useless 0 digits from decimals. 761 $cm = get_coursemodule_from_instance('quiz', $quiz->id); 762 $event = \mod_quiz\event\quiz_grade_updated::create([ 763 'context' => \context_module::instance($cm->id), 764 'objectid' => $quiz->id, 765 'other' => [ 766 'oldgrade' => $oldgrade + 0, 767 'newgrade' => $newgrade + 0 768 ] 769 ]); 770 $event->trigger(); 771 return true; 772 } 773 774 /** 775 * Save the overall grade for a user at a quiz in the quiz_grades table 776 * 777 * @param object $quiz The quiz for which the best grade is to be calculated and then saved. 778 * @param int $userid The userid to calculate the grade for. Defaults to the current user. 779 * @param array $attempts The attempts of this user. Useful if you are 780 * looping through many users. Attempts can be fetched in one master query to 781 * avoid repeated querying. 782 * @return bool Indicates success or failure. 783 */ 784 function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) { 785 global $DB, $OUTPUT, $USER; 786 787 if (empty($userid)) { 788 $userid = $USER->id; 789 } 790 791 if (!$attempts) { 792 // Get all the attempts made by the user. 793 $attempts = quiz_get_user_attempts($quiz->id, $userid); 794 } 795 796 // Calculate the best grade. 797 $bestgrade = quiz_calculate_best_grade($quiz, $attempts); 798 $bestgrade = quiz_rescale_grade($bestgrade, $quiz, false); 799 800 // Save the best grade in the database. 801 if (is_null($bestgrade)) { 802 $DB->delete_records('quiz_grades', array('quiz' => $quiz->id, 'userid' => $userid)); 803 804 } else if ($grade = $DB->get_record('quiz_grades', 805 array('quiz' => $quiz->id, 'userid' => $userid))) { 806 $grade->grade = $bestgrade; 807 $grade->timemodified = time(); 808 $DB->update_record('quiz_grades', $grade); 809 810 } else { 811 $grade = new stdClass(); 812 $grade->quiz = $quiz->id; 813 $grade->userid = $userid; 814 $grade->grade = $bestgrade; 815 $grade->timemodified = time(); 816 $DB->insert_record('quiz_grades', $grade); 817 } 818 819 quiz_update_grades($quiz, $userid); 820 } 821 822 /** 823 * Calculate the overall grade for a quiz given a number of attempts by a particular user. 824 * 825 * @param object $quiz the quiz settings object. 826 * @param array $attempts an array of all the user's attempts at this quiz in order. 827 * @return float the overall grade 828 */ 829 function quiz_calculate_best_grade($quiz, $attempts) { 830 831 switch ($quiz->grademethod) { 832 833 case QUIZ_ATTEMPTFIRST: 834 $firstattempt = reset($attempts); 835 return $firstattempt->sumgrades; 836 837 case QUIZ_ATTEMPTLAST: 838 $lastattempt = end($attempts); 839 return $lastattempt->sumgrades; 840 841 case QUIZ_GRADEAVERAGE: 842 $sum = 0; 843 $count = 0; 844 foreach ($attempts as $attempt) { 845 if (!is_null($attempt->sumgrades)) { 846 $sum += $attempt->sumgrades; 847 $count++; 848 } 849 } 850 if ($count == 0) { 851 return null; 852 } 853 return $sum / $count; 854 855 case QUIZ_GRADEHIGHEST: 856 default: 857 $max = null; 858 foreach ($attempts as $attempt) { 859 if ($attempt->sumgrades > $max) { 860 $max = $attempt->sumgrades; 861 } 862 } 863 return $max; 864 } 865 } 866 867 /** 868 * Update the final grade at this quiz for all students. 869 * 870 * This function is equivalent to calling quiz_save_best_grade for all 871 * users, but much more efficient. 872 * 873 * @param object $quiz the quiz settings. 874 */ 875 function quiz_update_all_final_grades($quiz) { 876 global $DB; 877 878 if (!$quiz->sumgrades) { 879 return; 880 } 881 882 $param = array('iquizid' => $quiz->id, 'istatefinished' => quiz_attempt::FINISHED); 883 $firstlastattemptjoin = "JOIN ( 884 SELECT 885 iquiza.userid, 886 MIN(attempt) AS firstattempt, 887 MAX(attempt) AS lastattempt 888 889 FROM {quiz_attempts} iquiza 890 891 WHERE 892 iquiza.state = :istatefinished AND 893 iquiza.preview = 0 AND 894 iquiza.quiz = :iquizid 895 896 GROUP BY iquiza.userid 897 ) first_last_attempts ON first_last_attempts.userid = quiza.userid"; 898 899 switch ($quiz->grademethod) { 900 case QUIZ_ATTEMPTFIRST: 901 // Because of the where clause, there will only be one row, but we 902 // must still use an aggregate function. 903 $select = 'MAX(quiza.sumgrades)'; 904 $join = $firstlastattemptjoin; 905 $where = 'quiza.attempt = first_last_attempts.firstattempt AND'; 906 break; 907 908 case QUIZ_ATTEMPTLAST: 909 // Because of the where clause, there will only be one row, but we 910 // must still use an aggregate function. 911 $select = 'MAX(quiza.sumgrades)'; 912 $join = $firstlastattemptjoin; 913 $where = 'quiza.attempt = first_last_attempts.lastattempt AND'; 914 break; 915 916 case QUIZ_GRADEAVERAGE: 917 $select = 'AVG(quiza.sumgrades)'; 918 $join = ''; 919 $where = ''; 920 break; 921 922 default: 923 case QUIZ_GRADEHIGHEST: 924 $select = 'MAX(quiza.sumgrades)'; 925 $join = ''; 926 $where = ''; 927 break; 928 } 929 930 if ($quiz->sumgrades >= 0.000005) { 931 $finalgrade = $select . ' * ' . ($quiz->grade / $quiz->sumgrades); 932 } else { 933 $finalgrade = '0'; 934 } 935 $param['quizid'] = $quiz->id; 936 $param['quizid2'] = $quiz->id; 937 $param['quizid3'] = $quiz->id; 938 $param['quizid4'] = $quiz->id; 939 $param['statefinished'] = quiz_attempt::FINISHED; 940 $param['statefinished2'] = quiz_attempt::FINISHED; 941 $finalgradesubquery = " 942 SELECT quiza.userid, $finalgrade AS newgrade 943 FROM {quiz_attempts} quiza 944 $join 945 WHERE 946 $where 947 quiza.state = :statefinished AND 948 quiza.preview = 0 AND 949 quiza.quiz = :quizid3 950 GROUP BY quiza.userid"; 951 952 $changedgrades = $DB->get_records_sql(" 953 SELECT users.userid, qg.id, qg.grade, newgrades.newgrade 954 955 FROM ( 956 SELECT userid 957 FROM {quiz_grades} qg 958 WHERE quiz = :quizid 959 UNION 960 SELECT DISTINCT userid 961 FROM {quiz_attempts} quiza2 962 WHERE 963 quiza2.state = :statefinished2 AND 964 quiza2.preview = 0 AND 965 quiza2.quiz = :quizid2 966 ) users 967 968 LEFT JOIN {quiz_grades} qg ON qg.userid = users.userid AND qg.quiz = :quizid4 969 970 LEFT JOIN ( 971 $finalgradesubquery 972 ) newgrades ON newgrades.userid = users.userid 973 974 WHERE 975 ABS(newgrades.newgrade - qg.grade) > 0.000005 OR 976 ((newgrades.newgrade IS NULL OR qg.grade IS NULL) AND NOT 977 (newgrades.newgrade IS NULL AND qg.grade IS NULL))", 978 // The mess on the previous line is detecting where the value is 979 // NULL in one column, and NOT NULL in the other, but SQL does 980 // not have an XOR operator, and MS SQL server can't cope with 981 // (newgrades.newgrade IS NULL) <> (qg.grade IS NULL). 982 $param); 983 984 $timenow = time(); 985 $todelete = array(); 986 foreach ($changedgrades as $changedgrade) { 987 988 if (is_null($changedgrade->newgrade)) { 989 $todelete[] = $changedgrade->userid; 990 991 } else if (is_null($changedgrade->grade)) { 992 $toinsert = new stdClass(); 993 $toinsert->quiz = $quiz->id; 994 $toinsert->userid = $changedgrade->userid; 995 $toinsert->timemodified = $timenow; 996 $toinsert->grade = $changedgrade->newgrade; 997 $DB->insert_record('quiz_grades', $toinsert); 998 999 } else { 1000 $toupdate = new stdClass(); 1001 $toupdate->id = $changedgrade->id; 1002 $toupdate->grade = $changedgrade->newgrade; 1003 $toupdate->timemodified = $timenow; 1004 $DB->update_record('quiz_grades', $toupdate); 1005 } 1006 } 1007 1008 if (!empty($todelete)) { 1009 list($test, $params) = $DB->get_in_or_equal($todelete); 1010 $DB->delete_records_select('quiz_grades', 'quiz = ? AND userid ' . $test, 1011 array_merge(array($quiz->id), $params)); 1012 } 1013 } 1014 1015 /** 1016 * Return summary of the number of settings override that exist. 1017 * 1018 * To get a nice display of this, see the quiz_override_summary_links() 1019 * quiz renderer method. 1020 * 1021 * @param stdClass $quiz the quiz settings. Only $quiz->id is used at the moment. 1022 * @param stdClass|cm_info $cm the cm object. Only $cm->course, $cm->groupmode and 1023 * $cm->groupingid fields are used at the moment. 1024 * @param int $currentgroup if there is a concept of current group where this method is being called 1025 * (e.g. a report) pass it in here. Default 0 which means no current group. 1026 * @return array like 'group' => 3, 'user' => 12] where 3 is the number of group overrides, 1027 * and 12 is the number of user ones. 1028 */ 1029 function quiz_override_summary(stdClass $quiz, stdClass $cm, int $currentgroup = 0): array { 1030 global $DB; 1031 1032 if ($currentgroup) { 1033 // Currently only interested in one group. 1034 $groupcount = $DB->count_records('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $currentgroup]); 1035 $usercount = $DB->count_records_sql(" 1036 SELECT COUNT(1) 1037 FROM {quiz_overrides} o 1038 JOIN {groups_members} gm ON o.userid = gm.userid 1039 WHERE o.quiz = ? 1040 AND gm.groupid = ? 1041 ", [$quiz->id, $currentgroup]); 1042 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'onegroup']; 1043 } 1044 1045 $quizgroupmode = groups_get_activity_groupmode($cm); 1046 $accessallgroups = ($quizgroupmode == NOGROUPS) || 1047 has_capability('moodle/site:accessallgroups', context_module::instance($cm->id)); 1048 1049 if ($accessallgroups) { 1050 // User can see all groups. 1051 $groupcount = $DB->count_records_select('quiz_overrides', 1052 'quiz = ? AND groupid IS NOT NULL', [$quiz->id]); 1053 $usercount = $DB->count_records_select('quiz_overrides', 1054 'quiz = ? AND userid IS NOT NULL', [$quiz->id]); 1055 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'allgroups']; 1056 1057 } else { 1058 // User can only see groups they are in. 1059 $groups = groups_get_activity_allowed_groups($cm); 1060 if (!$groups) { 1061 return ['group' => 0, 'user' => 0, 'mode' => 'somegroups']; 1062 } 1063 1064 list($groupidtest, $params) = $DB->get_in_or_equal(array_keys($groups)); 1065 $params[] = $quiz->id; 1066 1067 $groupcount = $DB->count_records_select('quiz_overrides', 1068 "groupid $groupidtest AND quiz = ?", $params); 1069 $usercount = $DB->count_records_sql(" 1070 SELECT COUNT(1) 1071 FROM {quiz_overrides} o 1072 JOIN {groups_members} gm ON o.userid = gm.userid 1073 WHERE gm.groupid $groupidtest 1074 AND o.quiz = ? 1075 ", $params); 1076 1077 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'somegroups']; 1078 } 1079 } 1080 1081 /** 1082 * Efficiently update check state time on all open attempts 1083 * 1084 * @param array $conditions optional restrictions on which attempts to update 1085 * Allowed conditions: 1086 * courseid => (array|int) attempts in given course(s) 1087 * userid => (array|int) attempts for given user(s) 1088 * quizid => (array|int) attempts in given quiz(s) 1089 * groupid => (array|int) quizzes with some override for given group(s) 1090 * 1091 */ 1092 function quiz_update_open_attempts(array $conditions) { 1093 global $DB; 1094 1095 foreach ($conditions as &$value) { 1096 if (!is_array($value)) { 1097 $value = array($value); 1098 } 1099 } 1100 1101 $params = array(); 1102 $wheres = array("quiza.state IN ('inprogress', 'overdue')"); 1103 $iwheres = array("iquiza.state IN ('inprogress', 'overdue')"); 1104 1105 if (isset($conditions['courseid'])) { 1106 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'cid'); 1107 $params = array_merge($params, $inparams); 1108 $wheres[] = "quiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)"; 1109 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'icid'); 1110 $params = array_merge($params, $inparams); 1111 $iwheres[] = "iquiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)"; 1112 } 1113 1114 if (isset($conditions['userid'])) { 1115 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'uid'); 1116 $params = array_merge($params, $inparams); 1117 $wheres[] = "quiza.userid $incond"; 1118 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'iuid'); 1119 $params = array_merge($params, $inparams); 1120 $iwheres[] = "iquiza.userid $incond"; 1121 } 1122 1123 if (isset($conditions['quizid'])) { 1124 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'qid'); 1125 $params = array_merge($params, $inparams); 1126 $wheres[] = "quiza.quiz $incond"; 1127 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'iqid'); 1128 $params = array_merge($params, $inparams); 1129 $iwheres[] = "iquiza.quiz $incond"; 1130 } 1131 1132 if (isset($conditions['groupid'])) { 1133 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'gid'); 1134 $params = array_merge($params, $inparams); 1135 $wheres[] = "quiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)"; 1136 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'igid'); 1137 $params = array_merge($params, $inparams); 1138 $iwheres[] = "iquiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)"; 1139 } 1140 1141 // SQL to compute timeclose and timelimit for each attempt: 1142 $quizausersql = quiz_get_attempt_usertime_sql( 1143 implode("\n AND ", $iwheres)); 1144 1145 // SQL to compute the new timecheckstate 1146 $timecheckstatesql = " 1147 CASE WHEN quizauser.usertimelimit = 0 AND quizauser.usertimeclose = 0 THEN NULL 1148 WHEN quizauser.usertimelimit = 0 THEN quizauser.usertimeclose 1149 WHEN quizauser.usertimeclose = 0 THEN quiza.timestart + quizauser.usertimelimit 1150 WHEN quiza.timestart + quizauser.usertimelimit < quizauser.usertimeclose THEN quiza.timestart + quizauser.usertimelimit 1151 ELSE quizauser.usertimeclose END + 1152 CASE WHEN quiza.state = 'overdue' THEN quiz.graceperiod ELSE 0 END"; 1153 1154 // SQL to select which attempts to process 1155 $attemptselect = implode("\n AND ", $wheres); 1156 1157 /* 1158 * Each database handles updates with inner joins differently: 1159 * - mysql does not allow a FROM clause 1160 * - postgres and mssql allow FROM but handle table aliases differently 1161 * - oracle requires a subquery 1162 * 1163 * Different code for each database. 1164 */ 1165 1166 $dbfamily = $DB->get_dbfamily(); 1167 if ($dbfamily == 'mysql') { 1168 $updatesql = "UPDATE {quiz_attempts} quiza 1169 JOIN {quiz} quiz ON quiz.id = quiza.quiz 1170 JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id 1171 SET quiza.timecheckstate = $timecheckstatesql 1172 WHERE $attemptselect"; 1173 } else if ($dbfamily == 'postgres') { 1174 $updatesql = "UPDATE {quiz_attempts} quiza 1175 SET timecheckstate = $timecheckstatesql 1176 FROM {quiz} quiz, ( $quizausersql ) quizauser 1177 WHERE quiz.id = quiza.quiz 1178 AND quizauser.id = quiza.id 1179 AND $attemptselect"; 1180 } else if ($dbfamily == 'mssql') { 1181 $updatesql = "UPDATE quiza 1182 SET timecheckstate = $timecheckstatesql 1183 FROM {quiz_attempts} quiza 1184 JOIN {quiz} quiz ON quiz.id = quiza.quiz 1185 JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id 1186 WHERE $attemptselect"; 1187 } else { 1188 // oracle, sqlite and others 1189 $updatesql = "UPDATE {quiz_attempts} quiza 1190 SET timecheckstate = ( 1191 SELECT $timecheckstatesql 1192 FROM {quiz} quiz, ( $quizausersql ) quizauser 1193 WHERE quiz.id = quiza.quiz 1194 AND quizauser.id = quiza.id 1195 ) 1196 WHERE $attemptselect"; 1197 } 1198 1199 $DB->execute($updatesql, $params); 1200 } 1201 1202 /** 1203 * Returns SQL to compute timeclose and timelimit for every attempt, taking into account user and group overrides. 1204 * The query used herein is very similar to the one in function quiz_get_user_timeclose, so, in case you 1205 * would change either one of them, make sure to apply your changes to both. 1206 * 1207 * @param string $redundantwhereclauses extra where clauses to add to the subquery 1208 * for performance. These can use the table alias iquiza for the quiz attempts table. 1209 * @return string SQL select with columns attempt.id, usertimeclose, usertimelimit. 1210 */ 1211 function quiz_get_attempt_usertime_sql($redundantwhereclauses = '') { 1212 if ($redundantwhereclauses) { 1213 $redundantwhereclauses = 'WHERE ' . $redundantwhereclauses; 1214 } 1215 // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede 1216 // any other group override 1217 $quizausersql = " 1218 SELECT iquiza.id, 1219 COALESCE(MAX(quo.timeclose), MAX(qgo1.timeclose), MAX(qgo2.timeclose), iquiz.timeclose) AS usertimeclose, 1220 COALESCE(MAX(quo.timelimit), MAX(qgo3.timelimit), MAX(qgo4.timelimit), iquiz.timelimit) AS usertimelimit 1221 1222 FROM {quiz_attempts} iquiza 1223 JOIN {quiz} iquiz ON iquiz.id = iquiza.quiz 1224 LEFT JOIN {quiz_overrides} quo ON quo.quiz = iquiza.quiz AND quo.userid = iquiza.userid 1225 LEFT JOIN {groups_members} gm ON gm.userid = iquiza.userid 1226 LEFT JOIN {quiz_overrides} qgo1 ON qgo1.quiz = iquiza.quiz AND qgo1.groupid = gm.groupid AND qgo1.timeclose = 0 1227 LEFT JOIN {quiz_overrides} qgo2 ON qgo2.quiz = iquiza.quiz AND qgo2.groupid = gm.groupid AND qgo2.timeclose > 0 1228 LEFT JOIN {quiz_overrides} qgo3 ON qgo3.quiz = iquiza.quiz AND qgo3.groupid = gm.groupid AND qgo3.timelimit = 0 1229 LEFT JOIN {quiz_overrides} qgo4 ON qgo4.quiz = iquiza.quiz AND qgo4.groupid = gm.groupid AND qgo4.timelimit > 0 1230 $redundantwhereclauses 1231 GROUP BY iquiza.id, iquiz.id, iquiz.timeclose, iquiz.timelimit"; 1232 return $quizausersql; 1233 } 1234 1235 /** 1236 * Return the attempt with the best grade for a quiz 1237 * 1238 * Which attempt is the best depends on $quiz->grademethod. If the grade 1239 * method is GRADEAVERAGE then this function simply returns the last attempt. 1240 * @return object The attempt with the best grade 1241 * @param object $quiz The quiz for which the best grade is to be calculated 1242 * @param array $attempts An array of all the attempts of the user at the quiz 1243 */ 1244 function quiz_calculate_best_attempt($quiz, $attempts) { 1245 1246 switch ($quiz->grademethod) { 1247 1248 case QUIZ_ATTEMPTFIRST: 1249 foreach ($attempts as $attempt) { 1250 return $attempt; 1251 } 1252 break; 1253 1254 case QUIZ_GRADEAVERAGE: // We need to do something with it. 1255 case QUIZ_ATTEMPTLAST: 1256 foreach ($attempts as $attempt) { 1257 $final = $attempt; 1258 } 1259 return $final; 1260 1261 default: 1262 case QUIZ_GRADEHIGHEST: 1263 $max = -1; 1264 foreach ($attempts as $attempt) { 1265 if ($attempt->sumgrades > $max) { 1266 $max = $attempt->sumgrades; 1267 $maxattempt = $attempt; 1268 } 1269 } 1270 return $maxattempt; 1271 } 1272 } 1273 1274 /** 1275 * @return array int => lang string the options for calculating the quiz grade 1276 * from the individual attempt grades. 1277 */ 1278 function quiz_get_grading_options() { 1279 return array( 1280 QUIZ_GRADEHIGHEST => get_string('gradehighest', 'quiz'), 1281 QUIZ_GRADEAVERAGE => get_string('gradeaverage', 'quiz'), 1282 QUIZ_ATTEMPTFIRST => get_string('attemptfirst', 'quiz'), 1283 QUIZ_ATTEMPTLAST => get_string('attemptlast', 'quiz') 1284 ); 1285 } 1286 1287 /** 1288 * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, 1289 * QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST. 1290 * @return the lang string for that option. 1291 */ 1292 function quiz_get_grading_option_name($option) { 1293 $strings = quiz_get_grading_options(); 1294 return $strings[$option]; 1295 } 1296 1297 /** 1298 * @return array string => lang string the options for handling overdue quiz 1299 * attempts. 1300 */ 1301 function quiz_get_overdue_handling_options() { 1302 return array( 1303 'autosubmit' => get_string('overduehandlingautosubmit', 'quiz'), 1304 'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'), 1305 'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'), 1306 ); 1307 } 1308 1309 /** 1310 * Get the choices for what size user picture to show. 1311 * @return array string => lang string the options for whether to display the user's picture. 1312 */ 1313 function quiz_get_user_image_options() { 1314 return array( 1315 QUIZ_SHOWIMAGE_NONE => get_string('shownoimage', 'quiz'), 1316 QUIZ_SHOWIMAGE_SMALL => get_string('showsmallimage', 'quiz'), 1317 QUIZ_SHOWIMAGE_LARGE => get_string('showlargeimage', 'quiz'), 1318 ); 1319 } 1320 1321 /** 1322 * Return an user's timeclose for all quizzes in a course, hereby taking into account group and user overrides. 1323 * 1324 * @param int $courseid the course id. 1325 * @return object An object with of all quizids and close unixdates in this course, taking into account the most lenient 1326 * overrides, if existing and 0 if no close date is set. 1327 */ 1328 function quiz_get_user_timeclose($courseid) { 1329 global $DB, $USER; 1330 1331 // For teacher and manager/admins return timeclose. 1332 if (has_capability('moodle/course:update', context_course::instance($courseid))) { 1333 $sql = "SELECT quiz.id, quiz.timeclose AS usertimeclose 1334 FROM {quiz} quiz 1335 WHERE quiz.course = :courseid"; 1336 1337 $results = $DB->get_records_sql($sql, array('courseid' => $courseid)); 1338 return $results; 1339 } 1340 1341 $sql = "SELECT q.id, 1342 COALESCE(v.userclose, v.groupclose, q.timeclose, 0) AS usertimeclose 1343 FROM ( 1344 SELECT quiz.id as quizid, 1345 MAX(quo.timeclose) AS userclose, MAX(qgo.timeclose) AS groupclose 1346 FROM {quiz} quiz 1347 LEFT JOIN {quiz_overrides} quo on quiz.id = quo.quiz AND quo.userid = :userid 1348 LEFT JOIN {groups_members} gm ON gm.userid = :useringroupid 1349 LEFT JOIN {quiz_overrides} qgo on quiz.id = qgo.quiz AND qgo.groupid = gm.groupid 1350 WHERE quiz.course = :courseid 1351 GROUP BY quiz.id) v 1352 JOIN {quiz} q ON q.id = v.quizid"; 1353 1354 $results = $DB->get_records_sql($sql, array('userid' => $USER->id, 'useringroupid' => $USER->id, 'courseid' => $courseid)); 1355 return $results; 1356 1357 } 1358 1359 /** 1360 * Get the choices to offer for the 'Questions per page' option. 1361 * @return array int => string. 1362 */ 1363 function quiz_questions_per_page_options() { 1364 $pageoptions = array(); 1365 $pageoptions[0] = get_string('neverallononepage', 'quiz'); 1366 $pageoptions[1] = get_string('everyquestion', 'quiz'); 1367 for ($i = 2; $i <= QUIZ_MAX_QPP_OPTION; ++$i) { 1368 $pageoptions[$i] = get_string('everynquestions', 'quiz', $i); 1369 } 1370 return $pageoptions; 1371 } 1372 1373 /** 1374 * Get the human-readable name for a quiz attempt state. 1375 * @param string $state one of the state constants like {@link quiz_attempt::IN_PROGRESS}. 1376 * @return string The lang string to describe that state. 1377 */ 1378 function quiz_attempt_state_name($state) { 1379 switch ($state) { 1380 case quiz_attempt::IN_PROGRESS: 1381 return get_string('stateinprogress', 'quiz'); 1382 case quiz_attempt::OVERDUE: 1383 return get_string('stateoverdue', 'quiz'); 1384 case quiz_attempt::FINISHED: 1385 return get_string('statefinished', 'quiz'); 1386 case quiz_attempt::ABANDONED: 1387 return get_string('stateabandoned', 'quiz'); 1388 default: 1389 throw new coding_exception('Unknown quiz attempt state.'); 1390 } 1391 } 1392 1393 // Other quiz functions //////////////////////////////////////////////////////// 1394 1395 /** 1396 * @param object $quiz the quiz. 1397 * @param int $cmid the course_module object for this quiz. 1398 * @param object $question the question. 1399 * @param string $returnurl url to return to after action is done. 1400 * @param int $variant which question variant to preview (optional). 1401 * @param bool $random if question is random, true. 1402 * @return string html for a number of icons linked to action pages for a 1403 * question - preview and edit / view icons depending on user capabilities. 1404 */ 1405 function quiz_question_action_icons($quiz, $cmid, $question, $returnurl, $variant = null) { 1406 $html = ''; 1407 if ($question->qtype !== 'random') { 1408 $html = quiz_question_preview_button($quiz, $question, false, $variant); 1409 } 1410 $html .= quiz_question_edit_button($cmid, $question, $returnurl); 1411 return $html; 1412 } 1413 1414 /** 1415 * @param int $cmid the course_module.id for this quiz. 1416 * @param object $question the question. 1417 * @param string $returnurl url to return to after action is done. 1418 * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon. 1419 * @return the HTML for an edit icon, view icon, or nothing for a question 1420 * (depending on permissions). 1421 */ 1422 function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') { 1423 global $CFG, $OUTPUT; 1424 1425 // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page. 1426 static $stredit = null; 1427 static $strview = null; 1428 if ($stredit === null) { 1429 $stredit = get_string('edit'); 1430 $strview = get_string('view'); 1431 } 1432 1433 // What sort of icon should we show? 1434 $action = ''; 1435 if (!empty($question->id) && 1436 (question_has_capability_on($question, 'edit') || 1437 question_has_capability_on($question, 'move'))) { 1438 $action = $stredit; 1439 $icon = 't/edit'; 1440 } else if (!empty($question->id) && 1441 question_has_capability_on($question, 'view')) { 1442 $action = $strview; 1443 $icon = 'i/info'; 1444 } 1445 1446 // Build the icon. 1447 if ($action) { 1448 if ($returnurl instanceof moodle_url) { 1449 $returnurl = $returnurl->out_as_local_url(false); 1450 } 1451 $questionparams = array('returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id); 1452 $questionurl = new moodle_url("$CFG->wwwroot/question/bank/editquestion/question.php", $questionparams); 1453 return '<a title="' . $action . '" href="' . $questionurl->out() . '" class="questioneditbutton">' . 1454 $OUTPUT->pix_icon($icon, $action) . $contentaftericon . 1455 '</a>'; 1456 } else if ($contentaftericon) { 1457 return '<span class="questioneditbutton">' . $contentaftericon . '</span>'; 1458 } else { 1459 return ''; 1460 } 1461 } 1462 1463 /** 1464 * @param object $quiz the quiz settings 1465 * @param object $question the question 1466 * @param int $variant which question variant to preview (optional). 1467 * @param int $restartversion version of the question to use when restarting the preview. 1468 * @return moodle_url to preview this question with the options from this quiz. 1469 */ 1470 function quiz_question_preview_url($quiz, $question, $variant = null, $restartversion = null) { 1471 // Get the appropriate display options. 1472 $displayoptions = mod_quiz_display_options::make_from_quiz($quiz, 1473 mod_quiz_display_options::DURING); 1474 1475 $maxmark = null; 1476 if (isset($question->maxmark)) { 1477 $maxmark = $question->maxmark; 1478 } 1479 1480 // Work out the correcte preview URL. 1481 return \qbank_previewquestion\helper::question_preview_url($question->id, $quiz->preferredbehaviour, 1482 $maxmark, $displayoptions, $variant, null, null, $restartversion); 1483 } 1484 1485 /** 1486 * @param object $quiz the quiz settings 1487 * @param object $question the question 1488 * @param bool $label if true, show the preview question label after the icon 1489 * @param int $variant which question variant to preview (optional). 1490 * @param bool $random if question is random, true. 1491 * @return string the HTML for a preview question icon. 1492 */ 1493 function quiz_question_preview_button($quiz, $question, $label = false, $variant = null, $random = null) { 1494 global $PAGE; 1495 if (!question_has_capability_on($question, 'use')) { 1496 return ''; 1497 } 1498 $structure = quiz::create($quiz->id)->get_structure(); 1499 if (!empty($question->slot)) { 1500 $requestedversion = $structure->get_slot_by_number($question->slot)->requestedversion 1501 ?? question_preview_options::ALWAYS_LATEST; 1502 } else { 1503 $requestedversion = question_preview_options::ALWAYS_LATEST; 1504 } 1505 return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon( 1506 $quiz, $question, $label, $variant, $requestedversion); 1507 } 1508 1509 /** 1510 * @param object $attempt the attempt. 1511 * @param object $context the quiz context. 1512 * @return int whether flags should be shown/editable to the current user for this attempt. 1513 */ 1514 function quiz_get_flag_option($attempt, $context) { 1515 global $USER; 1516 if (!has_capability('moodle/question:flag', $context)) { 1517 return question_display_options::HIDDEN; 1518 } else if ($attempt->userid == $USER->id) { 1519 return question_display_options::EDITABLE; 1520 } else { 1521 return question_display_options::VISIBLE; 1522 } 1523 } 1524 1525 /** 1526 * Work out what state this quiz attempt is in - in the sense used by 1527 * quiz_get_review_options, not in the sense of $attempt->state. 1528 * @param object $quiz the quiz settings 1529 * @param object $attempt the quiz_attempt database row. 1530 * @return int one of the mod_quiz_display_options::DURING, 1531 * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. 1532 */ 1533 function quiz_attempt_state($quiz, $attempt) { 1534 if ($attempt->state == quiz_attempt::IN_PROGRESS) { 1535 return mod_quiz_display_options::DURING; 1536 } else if ($quiz->timeclose && time() >= $quiz->timeclose) { 1537 return mod_quiz_display_options::AFTER_CLOSE; 1538 } else if (time() < $attempt->timefinish + 120) { 1539 return mod_quiz_display_options::IMMEDIATELY_AFTER; 1540 } else { 1541 return mod_quiz_display_options::LATER_WHILE_OPEN; 1542 } 1543 } 1544 1545 /** 1546 * The the appropraite mod_quiz_display_options object for this attempt at this 1547 * quiz right now. 1548 * 1549 * @param stdClass $quiz the quiz instance. 1550 * @param stdClass $attempt the attempt in question. 1551 * @param context $context the quiz context. 1552 * 1553 * @return mod_quiz_display_options 1554 */ 1555 function quiz_get_review_options($quiz, $attempt, $context) { 1556 $options = mod_quiz_display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt)); 1557 1558 $options->readonly = true; 1559 $options->flags = quiz_get_flag_option($attempt, $context); 1560 if (!empty($attempt->id)) { 1561 $options->questionreviewlink = new moodle_url('/mod/quiz/reviewquestion.php', 1562 array('attempt' => $attempt->id)); 1563 } 1564 1565 // Show a link to the comment box only for closed attempts. 1566 if (!empty($attempt->id) && $attempt->state == quiz_attempt::FINISHED && !$attempt->preview && 1567 !is_null($context) && has_capability('mod/quiz:grade', $context)) { 1568 $options->manualcomment = question_display_options::VISIBLE; 1569 $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php', 1570 array('attempt' => $attempt->id)); 1571 } 1572 1573 if (!is_null($context) && !$attempt->preview && 1574 has_capability('mod/quiz:viewreports', $context) && 1575 has_capability('moodle/grade:viewhidden', $context)) { 1576 // People who can see reports and hidden grades should be shown everything, 1577 // except during preview when teachers want to see what students see. 1578 $options->attempt = question_display_options::VISIBLE; 1579 $options->correctness = question_display_options::VISIBLE; 1580 $options->marks = question_display_options::MARK_AND_MAX; 1581 $options->feedback = question_display_options::VISIBLE; 1582 $options->numpartscorrect = question_display_options::VISIBLE; 1583 $options->manualcomment = question_display_options::VISIBLE; 1584 $options->generalfeedback = question_display_options::VISIBLE; 1585 $options->rightanswer = question_display_options::VISIBLE; 1586 $options->overallfeedback = question_display_options::VISIBLE; 1587 $options->history = question_display_options::VISIBLE; 1588 $options->userinfoinhistory = $attempt->userid; 1589 1590 } 1591 1592 return $options; 1593 } 1594 1595 /** 1596 * Combines the review options from a number of different quiz attempts. 1597 * Returns an array of two ojects, so the suggested way of calling this 1598 * funciton is: 1599 * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...) 1600 * 1601 * @param object $quiz the quiz instance. 1602 * @param array $attempts an array of attempt objects. 1603 * 1604 * @return array of two options objects, one showing which options are true for 1605 * at least one of the attempts, the other showing which options are true 1606 * for all attempts. 1607 */ 1608 function quiz_get_combined_reviewoptions($quiz, $attempts) { 1609 $fields = array('feedback', 'generalfeedback', 'rightanswer', 'overallfeedback'); 1610 $someoptions = new stdClass(); 1611 $alloptions = new stdClass(); 1612 foreach ($fields as $field) { 1613 $someoptions->$field = false; 1614 $alloptions->$field = true; 1615 } 1616 $someoptions->marks = question_display_options::HIDDEN; 1617 $alloptions->marks = question_display_options::MARK_AND_MAX; 1618 1619 // This shouldn't happen, but we need to prevent reveal information. 1620 if (empty($attempts)) { 1621 return array($someoptions, $someoptions); 1622 } 1623 1624 foreach ($attempts as $attempt) { 1625 $attemptoptions = mod_quiz_display_options::make_from_quiz($quiz, 1626 quiz_attempt_state($quiz, $attempt)); 1627 foreach ($fields as $field) { 1628 $someoptions->$field = $someoptions->$field || $attemptoptions->$field; 1629 $alloptions->$field = $alloptions->$field && $attemptoptions->$field; 1630 } 1631 $someoptions->marks = max($someoptions->marks, $attemptoptions->marks); 1632 $alloptions->marks = min($alloptions->marks, $attemptoptions->marks); 1633 } 1634 return array($someoptions, $alloptions); 1635 } 1636 1637 // Functions for sending notification messages ///////////////////////////////// 1638 1639 /** 1640 * Sends a confirmation message to the student confirming that the attempt was processed. 1641 * 1642 * @param object $a lots of useful information that can be used in the message 1643 * subject and body. 1644 * @param bool $studentisonline is the student currently interacting with Moodle? 1645 * 1646 * @return int|false as for {@link message_send()}. 1647 */ 1648 function quiz_send_confirmation($recipient, $a, $studentisonline) { 1649 1650 // Add information about the recipient to $a. 1651 // Don't do idnumber. we want idnumber to be the submitter's idnumber. 1652 $a->username = fullname($recipient); 1653 $a->userusername = $recipient->username; 1654 1655 // Prepare the message. 1656 $eventdata = new \core\message\message(); 1657 $eventdata->courseid = $a->courseid; 1658 $eventdata->component = 'mod_quiz'; 1659 $eventdata->name = 'confirmation'; 1660 $eventdata->notification = 1; 1661 1662 $eventdata->userfrom = core_user::get_noreply_user(); 1663 $eventdata->userto = $recipient; 1664 $eventdata->subject = get_string('emailconfirmsubject', 'quiz', $a); 1665 1666 if ($studentisonline) { 1667 $eventdata->fullmessage = get_string('emailconfirmbody', 'quiz', $a); 1668 } else { 1669 $eventdata->fullmessage = get_string('emailconfirmbodyautosubmit', 'quiz', $a); 1670 } 1671 1672 $eventdata->fullmessageformat = FORMAT_PLAIN; 1673 $eventdata->fullmessagehtml = ''; 1674 1675 $eventdata->smallmessage = get_string('emailconfirmsmall', 'quiz', $a); 1676 $eventdata->contexturl = $a->quizurl; 1677 $eventdata->contexturlname = $a->quizname; 1678 $eventdata->customdata = [ 1679 'cmid' => $a->quizcmid, 1680 'instance' => $a->quizid, 1681 'attemptid' => $a->attemptid, 1682 ]; 1683 1684 // ... and send it. 1685 return message_send($eventdata); 1686 } 1687 1688 /** 1689 * Sends notification messages to the interested parties that assign the role capability 1690 * 1691 * @param object $recipient user object of the intended recipient 1692 * @param object $a associative array of replaceable fields for the templates 1693 * 1694 * @return int|false as for {@link message_send()}. 1695 */ 1696 function quiz_send_notification($recipient, $submitter, $a) { 1697 global $PAGE; 1698 1699 // Recipient info for template. 1700 $a->useridnumber = $recipient->idnumber; 1701 $a->username = fullname($recipient); 1702 $a->userusername = $recipient->username; 1703 1704 // Prepare the message. 1705 $eventdata = new \core\message\message(); 1706 $eventdata->courseid = $a->courseid; 1707 $eventdata->component = 'mod_quiz'; 1708 $eventdata->name = 'submission'; 1709 $eventdata->notification = 1; 1710 1711 $eventdata->userfrom = $submitter; 1712 $eventdata->userto = $recipient; 1713 $eventdata->subject = get_string('emailnotifysubject', 'quiz', $a); 1714 $eventdata->fullmessage = get_string('emailnotifybody', 'quiz', $a); 1715 $eventdata->fullmessageformat = FORMAT_PLAIN; 1716 $eventdata->fullmessagehtml = ''; 1717 1718 $eventdata->smallmessage = get_string('emailnotifysmall', 'quiz', $a); 1719 $eventdata->contexturl = $a->quizreviewurl; 1720 $eventdata->contexturlname = $a->quizname; 1721 $userpicture = new user_picture($submitter); 1722 $userpicture->size = 1; // Use f1 size. 1723 $userpicture->includetoken = $recipient->id; // Generate an out-of-session token for the user receiving the message. 1724 $eventdata->customdata = [ 1725 'cmid' => $a->quizcmid, 1726 'instance' => $a->quizid, 1727 'attemptid' => $a->attemptid, 1728 'notificationiconurl' => $userpicture->get_url($PAGE)->out(false), 1729 ]; 1730 1731 // ... and send it. 1732 return message_send($eventdata); 1733 } 1734 1735 /** 1736 * Send all the requried messages when a quiz attempt is submitted. 1737 * 1738 * @param object $course the course 1739 * @param object $quiz the quiz 1740 * @param object $attempt this attempt just finished 1741 * @param object $context the quiz context 1742 * @param object $cm the coursemodule for this quiz 1743 * @param bool $studentisonline is the student currently interacting with Moodle? 1744 * 1745 * @return bool true if all necessary messages were sent successfully, else false. 1746 */ 1747 function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm, $studentisonline) { 1748 global $CFG, $DB; 1749 1750 // Do nothing if required objects not present. 1751 if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) { 1752 throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.'); 1753 } 1754 1755 $submitter = $DB->get_record('user', array('id' => $attempt->userid), '*', MUST_EXIST); 1756 1757 // Check for confirmation required. 1758 $sendconfirm = false; 1759 $notifyexcludeusers = ''; 1760 if (has_capability('mod/quiz:emailconfirmsubmission', $context, $submitter, false)) { 1761 $notifyexcludeusers = $submitter->id; 1762 $sendconfirm = true; 1763 } 1764 1765 // Check for notifications required. 1766 $notifyfields = 'u.id, u.username, u.idnumber, u.email, u.emailstop, u.lang, 1767 u.timezone, u.mailformat, u.maildisplay, u.auth, u.suspended, u.deleted, '; 1768 $userfieldsapi = \core_user\fields::for_name(); 1769 $notifyfields .= $userfieldsapi->get_sql('u', false, '', '', false)->selects; 1770 $groups = groups_get_all_groups($course->id, $submitter->id, $cm->groupingid); 1771 if (is_array($groups) && count($groups) > 0) { 1772 $groups = array_keys($groups); 1773 } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) { 1774 // If the user is not in a group, and the quiz is set to group mode, 1775 // then set $groups to a non-existant id so that only users with 1776 // 'moodle/site:accessallgroups' get notified. 1777 $groups = -1; 1778 } else { 1779 $groups = ''; 1780 } 1781 $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission', 1782 $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true); 1783 1784 if (empty($userstonotify) && !$sendconfirm) { 1785 return true; // Nothing to do. 1786 } 1787 1788 $a = new stdClass(); 1789 // Course info. 1790 $a->courseid = $course->id; 1791 $a->coursename = $course->fullname; 1792 $a->courseshortname = $course->shortname; 1793 // Quiz info. 1794 $a->quizname = $quiz->name; 1795 $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id; 1796 $a->quizreportlink = '<a href="' . $a->quizreporturl . '">' . 1797 format_string($quiz->name) . ' report</a>'; 1798 $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; 1799 $a->quizlink = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>'; 1800 $a->quizid = $quiz->id; 1801 $a->quizcmid = $cm->id; 1802 // Attempt info. 1803 $a->submissiontime = userdate($attempt->timefinish); 1804 $a->timetaken = format_time($attempt->timefinish - $attempt->timestart); 1805 $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id; 1806 $a->quizreviewlink = '<a href="' . $a->quizreviewurl . '">' . 1807 format_string($quiz->name) . ' review</a>'; 1808 $a->attemptid = $attempt->id; 1809 // Student who sat the quiz info. 1810 $a->studentidnumber = $submitter->idnumber; 1811 $a->studentname = fullname($submitter); 1812 $a->studentusername = $submitter->username; 1813 1814 $allok = true; 1815 1816 // Send notifications if required. 1817 if (!empty($userstonotify)) { 1818 foreach ($userstonotify as $recipient) { 1819 $allok = $allok && quiz_send_notification($recipient, $submitter, $a); 1820 } 1821 } 1822 1823 // Send confirmation if required. We send the student confirmation last, so 1824 // that if message sending is being intermittently buggy, which means we send 1825 // some but not all messages, and then try again later, then teachers may get 1826 // duplicate messages, but the student will always get exactly one. 1827 if ($sendconfirm) { 1828 $allok = $allok && quiz_send_confirmation($submitter, $a, $studentisonline); 1829 } 1830 1831 return $allok; 1832 } 1833 1834 /** 1835 * Send the notification message when a quiz attempt becomes overdue. 1836 * 1837 * @param quiz_attempt $attemptobj all the data about the quiz attempt. 1838 */ 1839 function quiz_send_overdue_message($attemptobj) { 1840 global $CFG, $DB; 1841 1842 $submitter = $DB->get_record('user', array('id' => $attemptobj->get_userid()), '*', MUST_EXIST); 1843 1844 if (!$attemptobj->has_capability('mod/quiz:emailwarnoverdue', $submitter->id, false)) { 1845 return; // Message not required. 1846 } 1847 1848 if (!$attemptobj->has_response_to_at_least_one_graded_question()) { 1849 return; // Message not required. 1850 } 1851 1852 // Prepare lots of useful information that admins might want to include in 1853 // the email message. 1854 $quizname = format_string($attemptobj->get_quiz_name()); 1855 1856 $deadlines = array(); 1857 if ($attemptobj->get_quiz()->timelimit) { 1858 $deadlines[] = $attemptobj->get_attempt()->timestart + $attemptobj->get_quiz()->timelimit; 1859 } 1860 if ($attemptobj->get_quiz()->timeclose) { 1861 $deadlines[] = $attemptobj->get_quiz()->timeclose; 1862 } 1863 $duedate = min($deadlines); 1864 $graceend = $duedate + $attemptobj->get_quiz()->graceperiod; 1865 1866 $a = new stdClass(); 1867 // Course info. 1868 $a->courseid = $attemptobj->get_course()->id; 1869 $a->coursename = format_string($attemptobj->get_course()->fullname); 1870 $a->courseshortname = format_string($attemptobj->get_course()->shortname); 1871 // Quiz info. 1872 $a->quizname = $quizname; 1873 $a->quizurl = $attemptobj->view_url(); 1874 $a->quizlink = '<a href="' . $a->quizurl . '">' . $quizname . '</a>'; 1875 // Attempt info. 1876 $a->attemptduedate = userdate($duedate); 1877 $a->attemptgraceend = userdate($graceend); 1878 $a->attemptsummaryurl = $attemptobj->summary_url()->out(false); 1879 $a->attemptsummarylink = '<a href="' . $a->attemptsummaryurl . '">' . $quizname . ' review</a>'; 1880 // Student's info. 1881 $a->studentidnumber = $submitter->idnumber; 1882 $a->studentname = fullname($submitter); 1883 $a->studentusername = $submitter->username; 1884 1885 // Prepare the message. 1886 $eventdata = new \core\message\message(); 1887 $eventdata->courseid = $a->courseid; 1888 $eventdata->component = 'mod_quiz'; 1889 $eventdata->name = 'attempt_overdue'; 1890 $eventdata->notification = 1; 1891 1892 $eventdata->userfrom = core_user::get_noreply_user(); 1893 $eventdata->userto = $submitter; 1894 $eventdata->subject = get_string('emailoverduesubject', 'quiz', $a); 1895 $eventdata->fullmessage = get_string('emailoverduebody', 'quiz', $a); 1896 $eventdata->fullmessageformat = FORMAT_PLAIN; 1897 $eventdata->fullmessagehtml = ''; 1898 1899 $eventdata->smallmessage = get_string('emailoverduesmall', 'quiz', $a); 1900 $eventdata->contexturl = $a->quizurl; 1901 $eventdata->contexturlname = $a->quizname; 1902 $eventdata->customdata = [ 1903 'cmid' => $attemptobj->get_cmid(), 1904 'instance' => $attemptobj->get_quizid(), 1905 'attemptid' => $attemptobj->get_attemptid(), 1906 ]; 1907 1908 // Send the message. 1909 return message_send($eventdata); 1910 } 1911 1912 /** 1913 * Handle the quiz_attempt_submitted event. 1914 * 1915 * This sends the confirmation and notification messages, if required. 1916 * 1917 * @param object $event the event object. 1918 */ 1919 function quiz_attempt_submitted_handler($event) { 1920 global $DB; 1921 1922 $course = $DB->get_record('course', array('id' => $event->courseid)); 1923 $attempt = $event->get_record_snapshot('quiz_attempts', $event->objectid); 1924 $quiz = $event->get_record_snapshot('quiz', $attempt->quiz); 1925 $cm = get_coursemodule_from_id('quiz', $event->get_context()->instanceid, $event->courseid); 1926 $eventdata = $event->get_data(); 1927 1928 if (!($course && $quiz && $cm && $attempt)) { 1929 // Something has been deleted since the event was raised. Therefore, the 1930 // event is no longer relevant. 1931 return true; 1932 } 1933 1934 // Update completion state. 1935 $completion = new completion_info($course); 1936 if ($completion->is_enabled($cm) && 1937 ($quiz->completionattemptsexhausted || $quiz->completionminattempts)) { 1938 $completion->update_state($cm, COMPLETION_COMPLETE, $event->userid); 1939 } 1940 return quiz_send_notification_messages($course, $quiz, $attempt, 1941 context_module::instance($cm->id), $cm, $eventdata['other']['studentisonline']); 1942 } 1943 1944 /** 1945 * Send the notification message when a quiz attempt has been manual graded. 1946 * 1947 * @param quiz_attempt $attemptobj Some data about the quiz attempt. 1948 * @param object $userto 1949 * @return int|false As for message_send. 1950 */ 1951 function quiz_send_notify_manual_graded_message(quiz_attempt $attemptobj, object $userto): ?int { 1952 global $CFG; 1953 1954 $quizname = format_string($attemptobj->get_quiz_name()); 1955 1956 $a = new stdClass(); 1957 // Course info. 1958 $a->courseid = $attemptobj->get_courseid(); 1959 $a->coursename = format_string($attemptobj->get_course()->fullname); 1960 // Quiz info. 1961 $a->quizname = $quizname; 1962 $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $attemptobj->get_cmid(); 1963 1964 // Attempt info. 1965 $a->attempttimefinish = userdate($attemptobj->get_attempt()->timefinish); 1966 // Student's info. 1967 $a->studentidnumber = $userto->idnumber; 1968 $a->studentname = fullname($userto); 1969 1970 $eventdata = new \core\message\message(); 1971 $eventdata->component = 'mod_quiz'; 1972 $eventdata->name = 'attempt_grading_complete'; 1973 $eventdata->userfrom = core_user::get_noreply_user(); 1974 $eventdata->userto = $userto; 1975 1976 $eventdata->subject = get_string('emailmanualgradedsubject', 'quiz', $a); 1977 $eventdata->fullmessage = get_string('emailmanualgradedbody', 'quiz', $a); 1978 $eventdata->fullmessageformat = FORMAT_PLAIN; 1979 $eventdata->fullmessagehtml = ''; 1980 1981 $eventdata->notification = 1; 1982 $eventdata->contexturl = $a->quizurl; 1983 $eventdata->contexturlname = $a->quizname; 1984 1985 // Send the message. 1986 return message_send($eventdata); 1987 } 1988 1989 /** 1990 * Handle groups_member_added event 1991 * 1992 * @param object $event the event object. 1993 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_added()}. 1994 */ 1995 function quiz_groups_member_added_handler($event) { 1996 debugging('quiz_groups_member_added_handler() is deprecated, please use ' . 1997 '\mod_quiz\group_observers::group_member_added() instead.', DEBUG_DEVELOPER); 1998 quiz_update_open_attempts(array('userid'=>$event->userid, 'groupid'=>$event->groupid)); 1999 } 2000 2001 /** 2002 * Handle groups_member_removed event 2003 * 2004 * @param object $event the event object. 2005 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_removed()}. 2006 */ 2007 function quiz_groups_member_removed_handler($event) { 2008 debugging('quiz_groups_member_removed_handler() is deprecated, please use ' . 2009 '\mod_quiz\group_observers::group_member_removed() instead.', DEBUG_DEVELOPER); 2010 quiz_update_open_attempts(array('userid'=>$event->userid, 'groupid'=>$event->groupid)); 2011 } 2012 2013 /** 2014 * Handle groups_group_deleted event 2015 * 2016 * @param object $event the event object. 2017 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_deleted()}. 2018 */ 2019 function quiz_groups_group_deleted_handler($event) { 2020 global $DB; 2021 debugging('quiz_groups_group_deleted_handler() is deprecated, please use ' . 2022 '\mod_quiz\group_observers::group_deleted() instead.', DEBUG_DEVELOPER); 2023 quiz_process_group_deleted_in_course($event->courseid); 2024 } 2025 2026 /** 2027 * Logic to happen when a/some group(s) has/have been deleted in a course. 2028 * 2029 * @param int $courseid The course ID. 2030 * @return void 2031 */ 2032 function quiz_process_group_deleted_in_course($courseid) { 2033 global $DB; 2034 2035 // It would be nice if we got the groupid that was deleted. 2036 // Instead, we just update all quizzes with orphaned group overrides. 2037 $sql = "SELECT o.id, o.quiz, o.groupid 2038 FROM {quiz_overrides} o 2039 JOIN {quiz} quiz ON quiz.id = o.quiz 2040 LEFT JOIN {groups} grp ON grp.id = o.groupid 2041 WHERE quiz.course = :courseid 2042 AND o.groupid IS NOT NULL 2043 AND grp.id IS NULL"; 2044 $params = array('courseid' => $courseid); 2045 $records = $DB->get_records_sql($sql, $params); 2046 if (!$records) { 2047 return; // Nothing to do. 2048 } 2049 $DB->delete_records_list('quiz_overrides', 'id', array_keys($records)); 2050 $cache = cache::make('mod_quiz', 'overrides'); 2051 foreach ($records as $record) { 2052 $cache->delete("{$record->quiz}_g_{$record->groupid}"); 2053 } 2054 quiz_update_open_attempts(['quizid' => array_unique(array_column($records, 'quiz'))]); 2055 } 2056 2057 /** 2058 * Handle groups_members_removed event 2059 * 2060 * @param object $event the event object. 2061 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_removed()}. 2062 */ 2063 function quiz_groups_members_removed_handler($event) { 2064 debugging('quiz_groups_members_removed_handler() is deprecated, please use ' . 2065 '\mod_quiz\group_observers::group_member_removed() instead.', DEBUG_DEVELOPER); 2066 if ($event->userid == 0) { 2067 quiz_update_open_attempts(array('courseid'=>$event->courseid)); 2068 } else { 2069 quiz_update_open_attempts(array('courseid'=>$event->courseid, 'userid'=>$event->userid)); 2070 } 2071 } 2072 2073 /** 2074 * Get the information about the standard quiz JavaScript module. 2075 * @return array a standard jsmodule structure. 2076 */ 2077 function quiz_get_js_module() { 2078 global $PAGE; 2079 2080 return array( 2081 'name' => 'mod_quiz', 2082 'fullpath' => '/mod/quiz/module.js', 2083 'requires' => array('base', 'dom', 'event-delegate', 'event-key', 2084 'core_question_engine'), 2085 'strings' => array( 2086 array('cancel', 'moodle'), 2087 array('flagged', 'question'), 2088 array('functiondisabledbysecuremode', 'quiz'), 2089 array('startattempt', 'quiz'), 2090 array('timesup', 'quiz'), 2091 ), 2092 ); 2093 } 2094 2095 2096 /** 2097 * An extension of question_display_options that includes the extra options used 2098 * by the quiz. 2099 * 2100 * @copyright 2010 The Open University 2101 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 2102 */ 2103 class mod_quiz_display_options extends question_display_options { 2104 /**#@+ 2105 * @var integer bits used to indicate various times in relation to a 2106 * quiz attempt. 2107 */ 2108 const DURING = 0x10000; 2109 const IMMEDIATELY_AFTER = 0x01000; 2110 const LATER_WHILE_OPEN = 0x00100; 2111 const AFTER_CLOSE = 0x00010; 2112 /**#@-*/ 2113 2114 /** 2115 * @var boolean if this is false, then the student is not allowed to review 2116 * anything about the attempt. 2117 */ 2118 public $attempt = true; 2119 2120 /** 2121 * @var boolean if this is false, then the student is not allowed to review 2122 * anything about the attempt. 2123 */ 2124 public $overallfeedback = self::VISIBLE; 2125 2126 /** 2127 * Set up the various options from the quiz settings, and a time constant. 2128 * @param object $quiz the quiz settings. 2129 * @param int $one of the {@link DURING}, {@link IMMEDIATELY_AFTER}, 2130 * {@link LATER_WHILE_OPEN} or {@link AFTER_CLOSE} constants. 2131 * @return mod_quiz_display_options set up appropriately. 2132 */ 2133 public static function make_from_quiz($quiz, $when) { 2134 $options = new self(); 2135 2136 $options->attempt = self::extract($quiz->reviewattempt, $when, true, false); 2137 $options->correctness = self::extract($quiz->reviewcorrectness, $when); 2138 $options->marks = self::extract($quiz->reviewmarks, $when, 2139 self::MARK_AND_MAX, self::MAX_ONLY); 2140 $options->feedback = self::extract($quiz->reviewspecificfeedback, $when); 2141 $options->generalfeedback = self::extract($quiz->reviewgeneralfeedback, $when); 2142 $options->rightanswer = self::extract($quiz->reviewrightanswer, $when); 2143 $options->overallfeedback = self::extract($quiz->reviewoverallfeedback, $when); 2144 2145 $options->numpartscorrect = $options->feedback; 2146 $options->manualcomment = $options->feedback; 2147 2148 if ($quiz->questiondecimalpoints != -1) { 2149 $options->markdp = $quiz->questiondecimalpoints; 2150 } else { 2151 $options->markdp = $quiz->decimalpoints; 2152 } 2153 2154 return $options; 2155 } 2156 2157 protected static function extract($bitmask, $bit, 2158 $whenset = self::VISIBLE, $whennotset = self::HIDDEN) { 2159 if ($bitmask & $bit) { 2160 return $whenset; 2161 } else { 2162 return $whennotset; 2163 } 2164 } 2165 } 2166 2167 /** 2168 * A {@link qubaid_condition} for finding all the question usages belonging to 2169 * a particular quiz. 2170 * 2171 * @copyright 2010 The Open University 2172 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 2173 */ 2174 class qubaids_for_quiz extends qubaid_join { 2175 public function __construct($quizid, $includepreviews = true, $onlyfinished = false) { 2176 $where = 'quiza.quiz = :quizaquiz'; 2177 $params = array('quizaquiz' => $quizid); 2178 2179 if (!$includepreviews) { 2180 $where .= ' AND preview = 0'; 2181 } 2182 2183 if ($onlyfinished) { 2184 $where .= ' AND state = :statefinished'; 2185 $params['statefinished'] = quiz_attempt::FINISHED; 2186 } 2187 2188 parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); 2189 } 2190 } 2191 2192 /** 2193 * A {@link qubaid_condition} for finding all the question usages belonging to a particular user and quiz combination. 2194 * 2195 * @copyright 2018 Andrew Nicols <andrwe@nicols.co.uk> 2196 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 2197 */ 2198 class qubaids_for_quiz_user extends qubaid_join { 2199 /** 2200 * Constructor for this qubaid. 2201 * 2202 * @param int $quizid The quiz to search. 2203 * @param int $userid The user to filter on 2204 * @param bool $includepreviews Whether to include preview attempts 2205 * @param bool $onlyfinished Whether to only include finished attempts or not 2206 */ 2207 public function __construct($quizid, $userid, $includepreviews = true, $onlyfinished = false) { 2208 $where = 'quiza.quiz = :quizaquiz AND quiza.userid = :quizauserid'; 2209 $params = [ 2210 'quizaquiz' => $quizid, 2211 'quizauserid' => $userid, 2212 ]; 2213 2214 if (!$includepreviews) { 2215 $where .= ' AND preview = 0'; 2216 } 2217 2218 if ($onlyfinished) { 2219 $where .= ' AND state = :statefinished'; 2220 $params['statefinished'] = quiz_attempt::FINISHED; 2221 } 2222 2223 parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); 2224 } 2225 } 2226 2227 /** 2228 * Creates a textual representation of a question for display. 2229 * 2230 * @param object $question A question object from the database questions table 2231 * @param bool $showicon If true, show the question's icon with the question. False by default. 2232 * @param bool $showquestiontext If true (default), show question text after question name. 2233 * If false, show only question name. 2234 * @param bool $showidnumber If true, show the question's idnumber, if any. False by default. 2235 * @param core_tag_tag[]|bool $showtags if array passed, show those tags. Else, if true, get and show tags, 2236 * else, don't show tags (which is the default). 2237 * @return string HTML fragment. 2238 */ 2239 function quiz_question_tostring($question, $showicon = false, $showquestiontext = true, 2240 $showidnumber = false, $showtags = false) { 2241 global $OUTPUT; 2242 $result = ''; 2243 2244 // Question name. 2245 $name = shorten_text(format_string($question->name), 200); 2246 if ($showicon) { 2247 $name .= print_question_icon($question) . ' ' . $name; 2248 } 2249 $result .= html_writer::span($name, 'questionname'); 2250 2251 // Question idnumber. 2252 if ($showidnumber && $question->idnumber !== null && $question->idnumber !== '') { 2253 $result .= ' ' . html_writer::span( 2254 html_writer::span(get_string('idnumber', 'question'), 'accesshide') . 2255 ' ' . s($question->idnumber), 'badge badge-primary'); 2256 } 2257 2258 // Question tags. 2259 if (is_array($showtags)) { 2260 $tags = $showtags; 2261 } else if ($showtags) { 2262 $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); 2263 } else { 2264 $tags = []; 2265 } 2266 if ($tags) { 2267 $result .= $OUTPUT->tag_list($tags, null, 'd-inline', 0, null, true); 2268 } 2269 2270 // Question text. 2271 if ($showquestiontext) { 2272 $questiontext = question_utils::to_plain_text($question->questiontext, 2273 $question->questiontextformat, ['noclean' => true, 'para' => false, 'filter' => false]); 2274 $questiontext = shorten_text($questiontext, 50); 2275 if ($questiontext) { 2276 $result .= ' ' . html_writer::span(s($questiontext), 'questiontext'); 2277 } 2278 } 2279 2280 return $result; 2281 } 2282 2283 /** 2284 * Verify that the question exists, and the user has permission to use it. 2285 * Does not return. Throws an exception if the question cannot be used. 2286 * @param int $questionid The id of the question. 2287 */ 2288 function quiz_require_question_use($questionid) { 2289 global $DB; 2290 $question = $DB->get_record('question', array('id' => $questionid), '*', MUST_EXIST); 2291 question_require_capability_on($question, 'use'); 2292 } 2293 2294 /** 2295 * Verify that the question exists, and the user has permission to use it. 2296 * 2297 * @deprecated in 4.1 use mod_quiz\structure::has_use_capability(...) instead. 2298 * 2299 * @param object $quiz the quiz settings. 2300 * @param int $slot which question in the quiz to test. 2301 * @return bool whether the user can use this question. 2302 */ 2303 function quiz_has_question_use($quiz, $slot) { 2304 global $DB; 2305 2306 debugging('Deprecated. Please use mod_quiz\structure::has_use_capability instead.'); 2307 2308 $sql = 'SELECT q.* 2309 FROM {quiz_slots} slot 2310 JOIN {question_references} qre ON qre.itemid = slot.id 2311 JOIN {question_bank_entries} qbe ON qbe.id = qre.questionbankentryid 2312 JOIN {question_versions} qve ON qve.questionbankentryid = qbe.id 2313 JOIN {question} q ON q.id = qve.questionid 2314 WHERE slot.quizid = ? 2315 AND slot.slot = ? 2316 AND qre.component = ? 2317 AND qre.questionarea = ?'; 2318 2319 $question = $DB->get_record_sql($sql, [$quiz->id, $slot, 'mod_quiz', 'slot']); 2320 2321 if (!$question) { 2322 return false; 2323 } 2324 return question_has_capability_on($question, 'use'); 2325 } 2326 2327 /** 2328 * Add a question to a quiz 2329 * 2330 * Adds a question to a quiz by updating $quiz as well as the 2331 * quiz and quiz_slots tables. It also adds a page break if required. 2332 * @param int $questionid The id of the question to be added 2333 * @param object $quiz The extended quiz object as used by edit.php 2334 * This is updated by this function 2335 * @param int $page Which page in quiz to add the question on. If 0 (default), 2336 * add at the end 2337 * @param float $maxmark The maximum mark to set for this question. (Optional, 2338 * defaults to question.defaultmark. 2339 * @return bool false if the question was already in the quiz 2340 */ 2341 function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) { 2342 global $DB; 2343 2344 if (!isset($quiz->cmid)) { 2345 $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); 2346 $quiz->cmid = $cm->id; 2347 } 2348 2349 // Make sue the question is not of the "random" type. 2350 $questiontype = $DB->get_field('question', 'qtype', array('id' => $questionid)); 2351 if ($questiontype == 'random') { 2352 throw new coding_exception( 2353 'Adding "random" questions via quiz_add_quiz_question() is deprecated. Please use quiz_add_random_questions().' 2354 ); 2355 } 2356 2357 $trans = $DB->start_delegated_transaction(); 2358 2359 $sql = "SELECT qbe.id 2360 FROM {quiz_slots} slot 2361 JOIN {question_references} qr ON qr.itemid = slot.id 2362 JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid 2363 WHERE slot.quizid = ? 2364 AND qr.component = ? 2365 AND qr.questionarea = ? 2366 AND qr.usingcontextid = ?"; 2367 2368 $questionslots = $DB->get_records_sql($sql, [$quiz->id, 'mod_quiz', 'slot', 2369 context_module::instance($quiz->cmid)->id]); 2370 2371 $currententry = get_question_bank_entry($questionid); 2372 2373 if (array_key_exists($currententry->id, $questionslots)) { 2374 $trans->allow_commit(); 2375 return false; 2376 } 2377 2378 $sql = "SELECT slot.slot, slot.page, slot.id 2379 FROM {quiz_slots} slot 2380 WHERE slot.quizid = ? 2381 ORDER BY slot.slot"; 2382 2383 $slots = $DB->get_records_sql($sql, [$quiz->id]); 2384 2385 $maxpage = 1; 2386 $numonlastpage = 0; 2387 foreach ($slots as $slot) { 2388 if ($slot->page > $maxpage) { 2389 $maxpage = $slot->page; 2390 $numonlastpage = 1; 2391 } else { 2392 $numonlastpage += 1; 2393 } 2394 } 2395 2396 // Add the new instance. 2397 $slot = new stdClass(); 2398 $slot->quizid = $quiz->id; 2399 2400 if ($maxmark !== null) { 2401 $slot->maxmark = $maxmark; 2402 } else { 2403 $slot->maxmark = $DB->get_field('question', 'defaultmark', array('id' => $questionid)); 2404 } 2405 2406 if (is_int($page) && $page >= 1) { 2407 // Adding on a given page. 2408 $lastslotbefore = 0; 2409 foreach (array_reverse($slots) as $otherslot) { 2410 if ($otherslot->page > $page) { 2411 $DB->set_field('quiz_slots', 'slot', $otherslot->slot + 1, array('id' => $otherslot->id)); 2412 } else { 2413 $lastslotbefore = $otherslot->slot; 2414 break; 2415 } 2416 } 2417 $slot->slot = $lastslotbefore + 1; 2418 $slot->page = min($page, $maxpage + 1); 2419 2420 quiz_update_section_firstslots($quiz->id, 1, max($lastslotbefore, 1)); 2421 2422 } else { 2423 $lastslot = end($slots); 2424 if ($lastslot) { 2425 $slot->slot = $lastslot->slot + 1; 2426 } else { 2427 $slot->slot = 1; 2428 } 2429 if ($quiz->questionsperpage && $numonlastpage >= $quiz->questionsperpage) { 2430 $slot->page = $maxpage + 1; 2431 } else { 2432 $slot->page = $maxpage; 2433 } 2434 } 2435 2436 $slotid = $DB->insert_record('quiz_slots', $slot); 2437 2438 // Update or insert record in question_reference table. 2439 $sql = "SELECT DISTINCT qr.id, qr.itemid 2440 FROM {question} q 2441 JOIN {question_versions} qv ON q.id = qv.questionid 2442 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid 2443 JOIN {question_references} qr ON qbe.id = qr.questionbankentryid AND qr.version = qv.version 2444 JOIN {quiz_slots} qs ON qs.id = qr.itemid 2445 WHERE q.id = ? 2446 AND qs.id = ? 2447 AND qr.component = ? 2448 AND qr.questionarea = ?"; 2449 $qreferenceitem = $DB->get_record_sql($sql, [$questionid, $slotid, 'mod_quiz', 'slot']); 2450 2451 if (!$qreferenceitem) { 2452 // Create a new reference record for questions created already. 2453 $questionreferences = new \StdClass(); 2454 $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id; 2455 $questionreferences->component = 'mod_quiz'; 2456 $questionreferences->questionarea = 'slot'; 2457 $questionreferences->itemid = $slotid; 2458 $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id; 2459 $questionreferences->version = null; // Always latest. 2460 $DB->insert_record('question_references', $questionreferences); 2461 2462 } else if ($qreferenceitem->itemid === 0 || $qreferenceitem->itemid === null) { 2463 $questionreferences = new \StdClass(); 2464 $questionreferences->id = $qreferenceitem->id; 2465 $questionreferences->itemid = $slotid; 2466 $DB->update_record('question_references', $questionreferences); 2467 } else { 2468 // If the reference record exits for another quiz. 2469 $questionreferences = new \StdClass(); 2470 $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id; 2471 $questionreferences->component = 'mod_quiz'; 2472 $questionreferences->questionarea = 'slot'; 2473 $questionreferences->itemid = $slotid; 2474 $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id; 2475 $questionreferences->version = null; // Always latest. 2476 $DB->insert_record('question_references', $questionreferences); 2477 } 2478 2479 $trans->allow_commit(); 2480 2481 // Log slot created event. 2482 $cm = get_coursemodule_from_instance('quiz', $quiz->id); 2483 $event = \mod_quiz\event\slot_created::create([ 2484 'context' => context_module::instance($cm->id), 2485 'objectid' => $slotid, 2486 'other' => [ 2487 'quizid' => $quiz->id, 2488 'slotnumber' => $slot->slot, 2489 'page' => $slot->page 2490 ] 2491 ]); 2492 $event->trigger(); 2493 } 2494 2495 /** 2496 * Move all the section headings in a certain slot range by a certain offset. 2497 * 2498 * @param int $quizid the id of a quiz 2499 * @param int $direction amount to adjust section heading positions. Normally +1 or -1. 2500 * @param int $afterslot adjust headings that start after this slot. 2501 * @param int|null $beforeslot optionally, only adjust headings before this slot. 2502 */ 2503 function quiz_update_section_firstslots($quizid, $direction, $afterslot, $beforeslot = null) { 2504 global $DB; 2505 $where = 'quizid = ? AND firstslot > ?'; 2506 $params = [$direction, $quizid, $afterslot]; 2507 if ($beforeslot) { 2508 $where .= ' AND firstslot < ?'; 2509 $params[] = $beforeslot; 2510 } 2511 $firstslotschanges = $DB->get_records_select_menu('quiz_sections', 2512 $where, $params, '', 'firstslot, firstslot + ?'); 2513 update_field_with_unique_index('quiz_sections', 'firstslot', $firstslotschanges, ['quizid' => $quizid]); 2514 } 2515 2516 /** 2517 * Add a random question to the quiz at a given point. 2518 * @param stdClass $quiz the quiz settings. 2519 * @param int $addonpage the page on which to add the question. 2520 * @param int $categoryid the question category to add the question from. 2521 * @param int $number the number of random questions to add. 2522 * @param bool $includesubcategories whether to include questoins from subcategories. 2523 * @param int[] $tagids Array of tagids. The question that will be picked randomly should be tagged with all these tags. 2524 */ 2525 function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number, 2526 $includesubcategories, $tagids = []) { 2527 global $DB; 2528 2529 $category = $DB->get_record('question_categories', ['id' => $categoryid]); 2530 if (!$category) { 2531 new moodle_exception('invalidcategoryid'); 2532 } 2533 2534 $catcontext = context::instance_by_id($category->contextid); 2535 require_capability('moodle/question:useall', $catcontext); 2536 2537 // Tags for filter condition. 2538 $tags = \core_tag_tag::get_bulk($tagids, 'id, name'); 2539 $tagstrings = []; 2540 foreach ($tags as $tag) { 2541 $tagstrings[] = "{$tag->id},{$tag->name}"; 2542 } 2543 // Create the selected number of random questions. 2544 for ($i = 0; $i < $number; $i++) { 2545 // Set the filter conditions. 2546 $filtercondition = new stdClass(); 2547 $filtercondition->questioncategoryid = $categoryid; 2548 $filtercondition->includingsubcategories = $includesubcategories ? 1 : 0; 2549 if (!empty($tagstrings)) { 2550 $filtercondition->tags = $tagstrings; 2551 } 2552 2553 if (!isset($quiz->cmid)) { 2554 $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); 2555 $quiz->cmid = $cm->id; 2556 } 2557 2558 // Slot data. 2559 $randomslotdata = new stdClass(); 2560 $randomslotdata->quizid = $quiz->id; 2561 $randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id; 2562 $randomslotdata->questionscontextid = $category->contextid; 2563 $randomslotdata->maxmark = 1; 2564 2565 $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); 2566 $randomslot->set_quiz($quiz); 2567 $randomslot->set_filter_condition($filtercondition); 2568 $randomslot->insert($addonpage); 2569 } 2570 } 2571 2572 /** 2573 * Mark the activity completed (if required) and trigger the course_module_viewed event. 2574 * 2575 * @param stdClass $quiz quiz object 2576 * @param stdClass $course course object 2577 * @param stdClass $cm course module object 2578 * @param stdClass $context context object 2579 * @since Moodle 3.1 2580 */ 2581 function quiz_view($quiz, $course, $cm, $context) { 2582 2583 $params = array( 2584 'objectid' => $quiz->id, 2585 'context' => $context 2586 ); 2587 2588 $event = \mod_quiz\event\course_module_viewed::create($params); 2589 $event->add_record_snapshot('quiz', $quiz); 2590 $event->trigger(); 2591 2592 // Completion. 2593 $completion = new completion_info($course); 2594 $completion->set_module_viewed($cm); 2595 } 2596 2597 /** 2598 * Validate permissions for creating a new attempt and start a new preview attempt if required. 2599 * 2600 * @param quiz $quizobj quiz object 2601 * @param quiz_access_manager $accessmanager quiz access manager 2602 * @param bool $forcenew whether was required to start a new preview attempt 2603 * @param int $page page to jump to in the attempt 2604 * @param bool $redirect whether to redirect or throw exceptions (for web or ws usage) 2605 * @return array an array containing the attempt information, access error messages and the page to jump to in the attempt 2606 * @throws moodle_quiz_exception 2607 * @since Moodle 3.1 2608 */ 2609 function quiz_validate_new_attempt(quiz $quizobj, quiz_access_manager $accessmanager, $forcenew, $page, $redirect) { 2610 global $DB, $USER; 2611 $timenow = time(); 2612 2613 if ($quizobj->is_preview_user() && $forcenew) { 2614 $accessmanager->current_attempt_finished(); 2615 } 2616 2617 // Check capabilities. 2618 if (!$quizobj->is_preview_user()) { 2619 $quizobj->require_capability('mod/quiz:attempt'); 2620 } 2621 2622 // Check to see if a new preview was requested. 2623 if ($quizobj->is_preview_user() && $forcenew) { 2624 // To force the creation of a new preview, we mark the current attempt (if any) 2625 // as abandoned. It will then automatically be deleted below. 2626 $DB->set_field('quiz_attempts', 'state', quiz_attempt::ABANDONED, 2627 array('quiz' => $quizobj->get_quizid(), 'userid' => $USER->id)); 2628 } 2629 2630 // Look for an existing attempt. 2631 $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $USER->id, 'all', true); 2632 $lastattempt = end($attempts); 2633 2634 $attemptnumber = null; 2635 // If an in-progress attempt exists, check password then redirect to it. 2636 if ($lastattempt && ($lastattempt->state == quiz_attempt::IN_PROGRESS || 2637 $lastattempt->state == quiz_attempt::OVERDUE)) { 2638 $currentattemptid = $lastattempt->id; 2639 $messages = $accessmanager->prevent_access(); 2640 2641 // If the attempt is now overdue, deal with that. 2642 $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow, true); 2643 2644 // And, if the attempt is now no longer in progress, redirect to the appropriate place. 2645 if ($lastattempt->state == quiz_attempt::ABANDONED || $lastattempt->state == quiz_attempt::FINISHED) { 2646 if ($redirect) { 2647 redirect($quizobj->review_url($lastattempt->id)); 2648 } else { 2649 throw new moodle_quiz_exception($quizobj, 'attemptalreadyclosed'); 2650 } 2651 } 2652 2653 // If the page number was not explicitly in the URL, go to the current page. 2654 if ($page == -1) { 2655 $page = $lastattempt->currentpage; 2656 } 2657 2658 } else { 2659 while ($lastattempt && $lastattempt->preview) { 2660 $lastattempt = array_pop($attempts); 2661 } 2662 2663 // Get number for the next or unfinished attempt. 2664 if ($lastattempt) { 2665 $attemptnumber = $lastattempt->attempt + 1; 2666 } else { 2667 $lastattempt = false; 2668 $attemptnumber = 1; 2669 } 2670 $currentattemptid = null; 2671 2672 $messages = $accessmanager->prevent_access() + 2673 $accessmanager->prevent_new_attempt(count($attempts), $lastattempt); 2674 2675 if ($page == -1) { 2676 $page = 0; 2677 } 2678 } 2679 return array($currentattemptid, $attemptnumber, $lastattempt, $messages, $page); 2680 } 2681 2682 /** 2683 * Prepare and start a new attempt deleting the previous preview attempts. 2684 * 2685 * @param quiz $quizobj quiz object 2686 * @param int $attemptnumber the attempt number 2687 * @param object $lastattempt last attempt object 2688 * @param bool $offlineattempt whether is an offline attempt or not 2689 * @param array $forcedrandomquestions slot number => question id. Used for random questions, 2690 * to force the choice of a particular actual question. Intended for testing purposes only. 2691 * @param array $forcedvariants slot number => variant. Used for questions with variants, 2692 * to force the choice of a particular variant. Intended for testing purposes only. 2693 * @param int $userid Specific user id to create an attempt for that user, null for current logged in user 2694 * @return object the new attempt 2695 * @since Moodle 3.1 2696 */ 2697 function quiz_prepare_and_start_new_attempt(quiz $quizobj, $attemptnumber, $lastattempt, 2698 $offlineattempt = false, $forcedrandomquestions = [], $forcedvariants = [], $userid = null) { 2699 global $DB, $USER; 2700 2701 if ($userid === null) { 2702 $userid = $USER->id; 2703 $ispreviewuser = $quizobj->is_preview_user(); 2704 } else { 2705 $ispreviewuser = has_capability('mod/quiz:preview', $quizobj->get_context(), $userid); 2706 } 2707 // Delete any previous preview attempts belonging to this user. 2708 quiz_delete_previews($quizobj->get_quiz(), $userid); 2709 2710 $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); 2711 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); 2712 2713 // Create the new attempt and initialize the question sessions 2714 $timenow = time(); // Update time now, in case the server is running really slowly. 2715 $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $ispreviewuser, $userid); 2716 2717 if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) { 2718 $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, 2719 $forcedrandomquestions, $forcedvariants); 2720 } else { 2721 $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt); 2722 } 2723 2724 $transaction = $DB->start_delegated_transaction(); 2725 2726 // Init the timemodifiedoffline for offline attempts. 2727 if ($offlineattempt) { 2728 $attempt->timemodifiedoffline = $attempt->timemodified; 2729 } 2730 $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt); 2731 2732 $transaction->allow_commit(); 2733 2734 return $attempt; 2735 } 2736 2737 /** 2738 * Check if the given calendar_event is either a user or group override 2739 * event for quiz. 2740 * 2741 * @param calendar_event $event The calendar event to check 2742 * @return bool 2743 */ 2744 function quiz_is_overriden_calendar_event(\calendar_event $event) { 2745 global $DB; 2746 2747 if (!isset($event->modulename)) { 2748 return false; 2749 } 2750 2751 if ($event->modulename != 'quiz') { 2752 return false; 2753 } 2754 2755 if (!isset($event->instance)) { 2756 return false; 2757 } 2758 2759 if (!isset($event->userid) && !isset($event->groupid)) { 2760 return false; 2761 } 2762 2763 $overrideparams = [ 2764 'quiz' => $event->instance 2765 ]; 2766 2767 if (isset($event->groupid)) { 2768 $overrideparams['groupid'] = $event->groupid; 2769 } else if (isset($event->userid)) { 2770 $overrideparams['userid'] = $event->userid; 2771 } 2772 2773 return $DB->record_exists('quiz_overrides', $overrideparams); 2774 } 2775 2776 /** 2777 * Retrieves tag information for the given list of quiz slot ids. 2778 * Currently the only slots that have tags are random question slots. 2779 * 2780 * Example: 2781 * If we have 3 slots with id 1, 2, and 3. The first slot has two tags, the second 2782 * has one tag, and the third has zero tags. The return structure will look like: 2783 * [ 2784 * 1 => [ 2785 * quiz_slot_tags.id => { ...tag data... }, 2786 * quiz_slot_tags.id => { ...tag data... }, 2787 * ], 2788 * 2 => [ 2789 * quiz_slot_tags.id => { ...tag data... }, 2790 * ], 2791 * 3 => [], 2792 * ] 2793 * 2794 * @param int[] $slotids The list of id for the quiz slots. 2795 * @return array[] List of quiz_slot_tags records indexed by slot id. 2796 * @deprecated since Moodle 4.0 2797 * @todo Final deprecation on Moodle 4.4 MDL-72438 2798 */ 2799 function quiz_retrieve_tags_for_slot_ids($slotids) { 2800 debugging('Method quiz_retrieve_tags_for_slot_ids() is deprecated, ' . 2801 'see filtercondition->tags from the question_set_reference table.', DEBUG_DEVELOPER); 2802 global $DB; 2803 if (empty($slotids)) { 2804 return []; 2805 } 2806 2807 $slottags = $DB->get_records_list('quiz_slot_tags', 'slotid', $slotids); 2808 $tagsbyid = core_tag_tag::get_bulk(array_filter(array_column($slottags, 'tagid')), 'id, name'); 2809 $tagsbyname = false; // It will be loaded later if required. 2810 $emptytagids = array_reduce($slotids, function($carry, $slotid) { 2811 $carry[$slotid] = []; 2812 return $carry; 2813 }, []); 2814 2815 return array_reduce( 2816 $slottags, 2817 function($carry, $slottag) use ($slottags, $tagsbyid, $tagsbyname) { 2818 if (isset($tagsbyid[$slottag->tagid])) { 2819 // Make sure that we're returning the most updated tag name. 2820 $slottag->tagname = $tagsbyid[$slottag->tagid]->name; 2821 } else { 2822 if ($tagsbyname === false) { 2823 // We were hoping that this query could be avoided, but life 2824 // showed its other side to us! 2825 $tagcollid = core_tag_area::get_collection('core', 'question'); 2826 $tagsbyname = core_tag_tag::get_by_name_bulk( 2827 $tagcollid, 2828 array_column($slottags, 'tagname'), 2829 'id, name' 2830 ); 2831 } 2832 if (isset($tagsbyname[$slottag->tagname])) { 2833 // Make sure that we're returning the current tag id that matches 2834 // the given tag name. 2835 $slottag->tagid = $tagsbyname[$slottag->tagname]->id; 2836 } else { 2837 // The tag does not exist anymore (neither the tag id nor the tag name 2838 // matches an existing tag). 2839 // We still need to include this row in the result as some callers might 2840 // be interested in these rows. An example is the editing forms that still 2841 // need to display tag names even if they don't exist anymore. 2842 $slottag->tagid = null; 2843 } 2844 } 2845 2846 $carry[$slottag->slotid][$slottag->id] = $slottag; 2847 return $carry; 2848 }, 2849 $emptytagids 2850 ); 2851 } 2852 2853 /** 2854 * Get quiz attempt and handling error. 2855 * 2856 * @param int $attemptid the id of the current attempt. 2857 * @param int|null $cmid the course_module id for this quiz. 2858 * @return quiz_attempt $attemptobj all the data about the quiz attempt. 2859 * @throws moodle_exception 2860 */ 2861 function quiz_create_attempt_handling_errors($attemptid, $cmid = null) { 2862 try { 2863 $attempobj = quiz_attempt::create($attemptid); 2864 } catch (moodle_exception $e) { 2865 if (!empty($cmid)) { 2866 list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'quiz'); 2867 $continuelink = new moodle_url('/mod/quiz/view.php', array('id' => $cmid)); 2868 $context = context_module::instance($cm->id); 2869 if (has_capability('mod/quiz:preview', $context)) { 2870 throw new moodle_exception('attempterrorcontentchange', 'quiz', $continuelink); 2871 } else { 2872 throw new moodle_exception('attempterrorcontentchangeforuser', 'quiz', $continuelink); 2873 } 2874 } else { 2875 throw new moodle_exception('attempterrorinvalid', 'quiz'); 2876 } 2877 } 2878 if (!empty($cmid) && $attempobj->get_cmid() != $cmid) { 2879 throw new moodle_exception('invalidcoursemodule'); 2880 } else { 2881 return $attempobj; 2882 } 2883 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body