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