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