Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * 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 * Return summary of the number of settings override that exist. 973 * 974 * To get a nice display of this, see the quiz_override_summary_links() 975 * quiz renderer method. 976 * 977 * @param stdClass $quiz the quiz settings. Only $quiz->id is used at the moment. 978 * @param stdClass|cm_info $cm the cm object. Only $cm->course, $cm->groupmode and 979 * $cm->groupingid fields are used at the moment. 980 * @param int $currentgroup if there is a concept of current group where this method is being called 981 * (e.g. a report) pass it in here. Default 0 which means no current group. 982 * @return array like 'group' => 3, 'user' => 12] where 3 is the number of group overrides, 983 * and 12 is the number of user ones. 984 */ 985 function quiz_override_summary(stdClass $quiz, stdClass $cm, int $currentgroup = 0): array { 986 global $DB; 987 988 if ($currentgroup) { 989 // Currently only interested in one group. 990 $groupcount = $DB->count_records('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $currentgroup]); 991 $usercount = $DB->count_records_sql(" 992 SELECT COUNT(1) 993 FROM {quiz_overrides} o 994 JOIN {groups_members} gm ON o.userid = gm.userid 995 WHERE o.quiz = ? 996 AND gm.groupid = ? 997 ", [$quiz->id, $currentgroup]); 998 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'onegroup']; 999 } 1000 1001 $quizgroupmode = groups_get_activity_groupmode($cm); 1002 $accessallgroups = ($quizgroupmode == NOGROUPS) || 1003 has_capability('moodle/site:accessallgroups', context_module::instance($cm->id)); 1004 1005 if ($accessallgroups) { 1006 // User can see all groups. 1007 $groupcount = $DB->count_records_select('quiz_overrides', 1008 'quiz = ? AND groupid IS NOT NULL', [$quiz->id]); 1009 $usercount = $DB->count_records_select('quiz_overrides', 1010 'quiz = ? AND userid IS NOT NULL', [$quiz->id]); 1011 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'allgroups']; 1012 1013 } else { 1014 // User can only see groups they are in. 1015 $groups = groups_get_activity_allowed_groups($cm); 1016 if (!$groups) { 1017 return ['group' => 0, 'user' => 0, 'mode' => 'somegroups']; 1018 } 1019 1020 list($groupidtest, $params) = $DB->get_in_or_equal(array_keys($groups)); 1021 $params[] = $quiz->id; 1022 1023 $groupcount = $DB->count_records_select('quiz_overrides', 1024 "groupid $groupidtest AND quiz = ?", $params); 1025 $usercount = $DB->count_records_sql(" 1026 SELECT COUNT(1) 1027 FROM {quiz_overrides} o 1028 JOIN {groups_members} gm ON o.userid = gm.userid 1029 WHERE gm.groupid $groupidtest 1030 AND o.quiz = ? 1031 ", $params); 1032 1033 return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'somegroups']; 1034 } 1035 } 1036 1037 /** 1038 * Efficiently update check state time on all open attempts 1039 * 1040 * @param array $conditions optional restrictions on which attempts to update 1041 * Allowed conditions: 1042 * courseid => (array|int) attempts in given course(s) 1043 * userid => (array|int) attempts for given user(s) 1044 * quizid => (array|int) attempts in given quiz(s) 1045 * groupid => (array|int) quizzes with some override for given group(s) 1046 * 1047 */ 1048 function quiz_update_open_attempts(array $conditions) { 1049 global $DB; 1050 1051 foreach ($conditions as &$value) { 1052 if (!is_array($value)) { 1053 $value = array($value); 1054 } 1055 } 1056 1057 $params = array(); 1058 $wheres = array("quiza.state IN ('inprogress', 'overdue')"); 1059 $iwheres = array("iquiza.state IN ('inprogress', 'overdue')"); 1060 1061 if (isset($conditions['courseid'])) { 1062 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'cid'); 1063 $params = array_merge($params, $inparams); 1064 $wheres[] = "quiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)"; 1065 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'icid'); 1066 $params = array_merge($params, $inparams); 1067 $iwheres[] = "iquiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)"; 1068 } 1069 1070 if (isset($conditions['userid'])) { 1071 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'uid'); 1072 $params = array_merge($params, $inparams); 1073 $wheres[] = "quiza.userid $incond"; 1074 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'iuid'); 1075 $params = array_merge($params, $inparams); 1076 $iwheres[] = "iquiza.userid $incond"; 1077 } 1078 1079 if (isset($conditions['quizid'])) { 1080 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'qid'); 1081 $params = array_merge($params, $inparams); 1082 $wheres[] = "quiza.quiz $incond"; 1083 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'iqid'); 1084 $params = array_merge($params, $inparams); 1085 $iwheres[] = "iquiza.quiz $incond"; 1086 } 1087 1088 if (isset($conditions['groupid'])) { 1089 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'gid'); 1090 $params = array_merge($params, $inparams); 1091 $wheres[] = "quiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)"; 1092 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'igid'); 1093 $params = array_merge($params, $inparams); 1094 $iwheres[] = "iquiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)"; 1095 } 1096 1097 // SQL to compute timeclose and timelimit for each attempt: 1098 $quizausersql = quiz_get_attempt_usertime_sql( 1099 implode("\n AND ", $iwheres)); 1100 1101 // SQL to compute the new timecheckstate 1102 $timecheckstatesql = " 1103 CASE WHEN quizauser.usertimelimit = 0 AND quizauser.usertimeclose = 0 THEN NULL 1104 WHEN quizauser.usertimelimit = 0 THEN quizauser.usertimeclose 1105 WHEN quizauser.usertimeclose = 0 THEN quiza.timestart + quizauser.usertimelimit 1106 WHEN quiza.timestart + quizauser.usertimelimit < quizauser.usertimeclose THEN quiza.timestart + quizauser.usertimelimit 1107 ELSE quizauser.usertimeclose END + 1108 CASE WHEN quiza.state = 'overdue' THEN quiz.graceperiod ELSE 0 END"; 1109 1110 // SQL to select which attempts to process 1111 $attemptselect = implode("\n AND ", $wheres); 1112 1113 /* 1114 * Each database handles updates with inner joins differently: 1115 * - mysql does not allow a FROM clause 1116 * - postgres and mssql allow FROM but handle table aliases differently 1117 * - oracle requires a subquery 1118 * 1119 * Different code for each database. 1120 */ 1121 1122 $dbfamily = $DB->get_dbfamily(); 1123 if ($dbfamily == 'mysql') { 1124 $updatesql = "UPDATE {quiz_attempts} quiza 1125 JOIN {quiz} quiz ON quiz.id = quiza.quiz 1126 JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id 1127 SET quiza.timecheckstate = $timecheckstatesql 1128 WHERE $attemptselect"; 1129 } else if ($dbfamily == 'postgres') { 1130 $updatesql = "UPDATE {quiz_attempts} quiza 1131 SET timecheckstate = $timecheckstatesql 1132 FROM {quiz} quiz, ( $quizausersql ) quizauser 1133 WHERE quiz.id = quiza.quiz 1134 AND quizauser.id = quiza.id 1135 AND $attemptselect"; 1136 } else if ($dbfamily == 'mssql') { 1137 $updatesql = "UPDATE quiza 1138 SET timecheckstate = $timecheckstatesql 1139 FROM {quiz_attempts} quiza 1140 JOIN {quiz} quiz ON quiz.id = quiza.quiz 1141 JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id 1142 WHERE $attemptselect"; 1143 } else { 1144 // oracle, sqlite and others 1145 $updatesql = "UPDATE {quiz_attempts} quiza 1146 SET timecheckstate = ( 1147 SELECT $timecheckstatesql 1148 FROM {quiz} quiz, ( $quizausersql ) quizauser 1149 WHERE quiz.id = quiza.quiz 1150 AND quizauser.id = quiza.id 1151 ) 1152 WHERE $attemptselect"; 1153 } 1154 1155 $DB->execute($updatesql, $params); 1156 } 1157 1158 /** 1159 * Returns SQL to compute timeclose and timelimit for every attempt, taking into account user and group overrides. 1160 * The query used herein is very similar to the one in function quiz_get_user_timeclose, so, in case you 1161 * would change either one of them, make sure to apply your changes to both. 1162 * 1163 * @param string $redundantwhereclauses extra where clauses to add to the subquery 1164 * for performance. These can use the table alias iquiza for the quiz attempts table. 1165 * @return string SQL select with columns attempt.id, usertimeclose, usertimelimit. 1166 */ 1167 function quiz_get_attempt_usertime_sql($redundantwhereclauses = '') { 1168 if ($redundantwhereclauses) { 1169 $redundantwhereclauses = 'WHERE ' . $redundantwhereclauses; 1170 } 1171 // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede 1172 // any other group override 1173 $quizausersql = " 1174 SELECT iquiza.id, 1175 COALESCE(MAX(quo.timeclose), MAX(qgo1.timeclose), MAX(qgo2.timeclose), iquiz.timeclose) AS usertimeclose, 1176 COALESCE(MAX(quo.timelimit), MAX(qgo3.timelimit), MAX(qgo4.timelimit), iquiz.timelimit) AS usertimelimit 1177 1178 FROM {quiz_attempts} iquiza 1179 JOIN {quiz} iquiz ON iquiz.id = iquiza.quiz 1180 LEFT JOIN {quiz_overrides} quo ON quo.quiz = iquiza.quiz AND quo.userid = iquiza.userid 1181 LEFT JOIN {groups_members} gm ON gm.userid = iquiza.userid 1182 LEFT JOIN {quiz_overrides} qgo1 ON qgo1.quiz = iquiza.quiz AND qgo1.groupid = gm.groupid AND qgo1.timeclose = 0 1183 LEFT JOIN {quiz_overrides} qgo2 ON qgo2.quiz = iquiza.quiz AND qgo2.groupid = gm.groupid AND qgo2.timeclose > 0 1184 LEFT JOIN {quiz_overrides} qgo3 ON qgo3.quiz = iquiza.quiz AND qgo3.groupid = gm.groupid AND qgo3.timelimit = 0 1185 LEFT JOIN {quiz_overrides} qgo4 ON qgo4.quiz = iquiza.quiz AND qgo4.groupid = gm.groupid AND qgo4.timelimit > 0 1186 $redundantwhereclauses 1187 GROUP BY iquiza.id, iquiz.id, iquiz.timeclose, iquiz.timelimit"; 1188 return $quizausersql; 1189 } 1190 1191 /** 1192 * Return the attempt with the best grade for a quiz 1193 * 1194 * Which attempt is the best depends on $quiz->grademethod. If the grade 1195 * method is GRADEAVERAGE then this function simply returns the last attempt. 1196 * @return object The attempt with the best grade 1197 * @param object $quiz The quiz for which the best grade is to be calculated 1198 * @param array $attempts An array of all the attempts of the user at the quiz 1199 */ 1200 function quiz_calculate_best_attempt($quiz, $attempts) { 1201 1202 switch ($quiz->grademethod) { 1203 1204 case QUIZ_ATTEMPTFIRST: 1205 foreach ($attempts as $attempt) { 1206 return $attempt; 1207 } 1208 break; 1209 1210 case QUIZ_GRADEAVERAGE: // We need to do something with it. 1211 case QUIZ_ATTEMPTLAST: 1212 foreach ($attempts as $attempt) { 1213 $final = $attempt; 1214 } 1215 return $final; 1216 1217 default: 1218 case QUIZ_GRADEHIGHEST: 1219 $max = -1; 1220 foreach ($attempts as $attempt) { 1221 if ($attempt->sumgrades > $max) { 1222 $max = $attempt->sumgrades; 1223 $maxattempt = $attempt; 1224 } 1225 } 1226 return $maxattempt; 1227 } 1228 } 1229 1230 /** 1231 * @return array int => lang string the options for calculating the quiz grade 1232 * from the individual attempt grades. 1233 */ 1234 function quiz_get_grading_options() { 1235 return array( 1236 QUIZ_GRADEHIGHEST => get_string('gradehighest', 'quiz'), 1237 QUIZ_GRADEAVERAGE => get_string('gradeaverage', 'quiz'), 1238 QUIZ_ATTEMPTFIRST => get_string('attemptfirst', 'quiz'), 1239 QUIZ_ATTEMPTLAST => get_string('attemptlast', 'quiz') 1240 ); 1241 } 1242 1243 /** 1244 * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, 1245 * QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST. 1246 * @return the lang string for that option. 1247 */ 1248 function quiz_get_grading_option_name($option) { 1249 $strings = quiz_get_grading_options(); 1250 return $strings[$option]; 1251 } 1252 1253 /** 1254 * @return array string => lang string the options for handling overdue quiz 1255 * attempts. 1256 */ 1257 function quiz_get_overdue_handling_options() { 1258 return array( 1259 'autosubmit' => get_string('overduehandlingautosubmit', 'quiz'), 1260 'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'), 1261 'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'), 1262 ); 1263 } 1264 1265 /** 1266 * Get the choices for what size user picture to show. 1267 * @return array string => lang string the options for whether to display the user's picture. 1268 */ 1269 function quiz_get_user_image_options() { 1270 return array( 1271 QUIZ_SHOWIMAGE_NONE => get_string('shownoimage', 'quiz'), 1272 QUIZ_SHOWIMAGE_SMALL => get_string('showsmallimage', 'quiz'), 1273 QUIZ_SHOWIMAGE_LARGE => get_string('showlargeimage', 'quiz'), 1274 ); 1275 } 1276 1277 /** 1278 * Return an user's timeclose for all quizzes in a course, hereby taking into account group and user overrides. 1279 * 1280 * @param int $courseid the course id. 1281 * @return object An object with of all quizids and close unixdates in this course, taking into account the most lenient 1282 * overrides, if existing and 0 if no close date is set. 1283 */ 1284 function quiz_get_user_timeclose($courseid) { 1285 global $DB, $USER; 1286 1287 // For teacher and manager/admins return timeclose. 1288 if (has_capability('moodle/course:update', context_course::instance($courseid))) { 1289 $sql = "SELECT quiz.id, quiz.timeclose AS usertimeclose 1290 FROM {quiz} quiz 1291 WHERE quiz.course = :courseid"; 1292 1293 $results = $DB->get_records_sql($sql, array('courseid' => $courseid)); 1294 return $results; 1295 } 1296 1297 $sql = "SELECT q.id, 1298 COALESCE(v.userclose, v.groupclose, q.timeclose, 0) AS usertimeclose 1299 FROM ( 1300 SELECT quiz.id as quizid, 1301 MAX(quo.timeclose) AS userclose, MAX(qgo.timeclose) AS groupclose 1302 FROM {quiz} quiz 1303 LEFT JOIN {quiz_overrides} quo on quiz.id = quo.quiz AND quo.userid = :userid 1304 LEFT JOIN {groups_members} gm ON gm.userid = :useringroupid 1305 LEFT JOIN {quiz_overrides} qgo on quiz.id = qgo.quiz AND qgo.groupid = gm.groupid 1306 WHERE quiz.course = :courseid 1307 GROUP BY quiz.id) v 1308 JOIN {quiz} q ON q.id = v.quizid"; 1309 1310 $results = $DB->get_records_sql($sql, array('userid' => $USER->id, 'useringroupid' => $USER->id, 'courseid' => $courseid)); 1311 return $results; 1312 1313 } 1314 1315 /** 1316 * Get the choices to offer for the 'Questions per page' option. 1317 * @return array int => string. 1318 */ 1319 function quiz_questions_per_page_options() { 1320 $pageoptions = array(); 1321 $pageoptions[0] = get_string('neverallononepage', 'quiz'); 1322 $pageoptions[1] = get_string('everyquestion', 'quiz'); 1323 for ($i = 2; $i <= QUIZ_MAX_QPP_OPTION; ++$i) { 1324 $pageoptions[$i] = get_string('everynquestions', 'quiz', $i); 1325 } 1326 return $pageoptions; 1327 } 1328 1329 /** 1330 * Get the human-readable name for a quiz attempt state. 1331 * @param string $state one of the state constants like {@link quiz_attempt::IN_PROGRESS}. 1332 * @return string The lang string to describe that state. 1333 */ 1334 function quiz_attempt_state_name($state) { 1335 switch ($state) { 1336 case quiz_attempt::IN_PROGRESS: 1337 return get_string('stateinprogress', 'quiz'); 1338 case quiz_attempt::OVERDUE: 1339 return get_string('stateoverdue', 'quiz'); 1340 case quiz_attempt::FINISHED: 1341 return get_string('statefinished', 'quiz'); 1342 case quiz_attempt::ABANDONED: 1343 return get_string('stateabandoned', 'quiz'); 1344 default: 1345 throw new coding_exception('Unknown quiz attempt state.'); 1346 } 1347 } 1348 1349 // Other quiz functions //////////////////////////////////////////////////////// 1350 1351 /** 1352 * @param object $quiz the quiz. 1353 * @param int $cmid the course_module object for this quiz. 1354 * @param object $question the question. 1355 * @param string $returnurl url to return to after action is done. 1356 * @param int $variant which question variant to preview (optional). 1357 * @return string html for a number of icons linked to action pages for a 1358 * question - preview and edit / view icons depending on user capabilities. 1359 */ 1360 function quiz_question_action_icons($quiz, $cmid, $question, $returnurl, $variant = null) { 1361 $html = quiz_question_preview_button($quiz, $question, false, $variant) . ' ' . 1362 quiz_question_edit_button($cmid, $question, $returnurl); 1363 return $html; 1364 } 1365 1366 /** 1367 * @param int $cmid the course_module.id for this quiz. 1368 * @param object $question the question. 1369 * @param string $returnurl url to return to after action is done. 1370 * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon. 1371 * @return the HTML for an edit icon, view icon, or nothing for a question 1372 * (depending on permissions). 1373 */ 1374 function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') { 1375 global $CFG, $OUTPUT; 1376 1377 // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page. 1378 static $stredit = null; 1379 static $strview = null; 1380 if ($stredit === null) { 1381 $stredit = get_string('edit'); 1382 $strview = get_string('view'); 1383 } 1384 1385 // What sort of icon should we show? 1386 $action = ''; 1387 if (!empty($question->id) && 1388 (question_has_capability_on($question, 'edit') || 1389 question_has_capability_on($question, 'move'))) { 1390 $action = $stredit; 1391 $icon = 't/edit'; 1392 } else if (!empty($question->id) && 1393 question_has_capability_on($question, 'view')) { 1394 $action = $strview; 1395 $icon = 'i/info'; 1396 } 1397 1398 // Build the icon. 1399 if ($action) { 1400 if ($returnurl instanceof moodle_url) { 1401 $returnurl = $returnurl->out_as_local_url(false); 1402 } 1403 $questionparams = array('returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id); 1404 $questionurl = new moodle_url("$CFG->wwwroot/question/question.php", $questionparams); 1405 return '<a title="' . $action . '" href="' . $questionurl->out() . '" class="questioneditbutton">' . 1406 $OUTPUT->pix_icon($icon, $action) . $contentaftericon . 1407 '</a>'; 1408 } else if ($contentaftericon) { 1409 return '<span class="questioneditbutton">' . $contentaftericon . '</span>'; 1410 } else { 1411 return ''; 1412 } 1413 } 1414 1415 /** 1416 * @param object $quiz the quiz settings 1417 * @param object $question the question 1418 * @param int $variant which question variant to preview (optional). 1419 * @return moodle_url to preview this question with the options from this quiz. 1420 */ 1421 function quiz_question_preview_url($quiz, $question, $variant = null) { 1422 // Get the appropriate display options. 1423 $displayoptions = mod_quiz_display_options::make_from_quiz($quiz, 1424 mod_quiz_display_options::DURING); 1425 1426 $maxmark = null; 1427 if (isset($question->maxmark)) { 1428 $maxmark = $question->maxmark; 1429 } 1430 1431 // Work out the correcte preview URL. 1432 return question_preview_url($question->id, $quiz->preferredbehaviour, 1433 $maxmark, $displayoptions, $variant); 1434 } 1435 1436 /** 1437 * @param object $quiz the quiz settings 1438 * @param object $question the question 1439 * @param bool $label if true, show the preview question label after the icon 1440 * @param int $variant which question variant to preview (optional). 1441 * @return the HTML for a preview question icon. 1442 */ 1443 function quiz_question_preview_button($quiz, $question, $label = false, $variant = null) { 1444 global $PAGE; 1445 if (!question_has_capability_on($question, 'use')) { 1446 return ''; 1447 } 1448 1449 return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon($quiz, $question, $label, $variant); 1450 } 1451 1452 /** 1453 * @param object $attempt the attempt. 1454 * @param object $context the quiz context. 1455 * @return int whether flags should be shown/editable to the current user for this attempt. 1456 */ 1457 function quiz_get_flag_option($attempt, $context) { 1458 global $USER; 1459 if (!has_capability('moodle/question:flag', $context)) { 1460 return question_display_options::HIDDEN; 1461 } else if ($attempt->userid == $USER->id) { 1462 return question_display_options::EDITABLE; 1463 } else { 1464 return question_display_options::VISIBLE; 1465 } 1466 } 1467 1468 /** 1469 * Work out what state this quiz attempt is in - in the sense used by 1470 * quiz_get_review_options, not in the sense of $attempt->state. 1471 * @param object $quiz the quiz settings 1472 * @param object $attempt the quiz_attempt database row. 1473 * @return int one of the mod_quiz_display_options::DURING, 1474 * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. 1475 */ 1476 function quiz_attempt_state($quiz, $attempt) { 1477 if ($attempt->state == quiz_attempt::IN_PROGRESS) { 1478 return mod_quiz_display_options::DURING; 1479 } else if ($quiz->timeclose && time() >= $quiz->timeclose) { 1480 return mod_quiz_display_options::AFTER_CLOSE; 1481 } else if (time() < $attempt->timefinish + 120) { 1482 return mod_quiz_display_options::IMMEDIATELY_AFTER; 1483 } else { 1484 return mod_quiz_display_options::LATER_WHILE_OPEN; 1485 } 1486 } 1487 1488 /** 1489 * The the appropraite mod_quiz_display_options object for this attempt at this 1490 * quiz right now. 1491 * 1492 * @param stdClass $quiz the quiz instance. 1493 * @param stdClass $attempt the attempt in question. 1494 * @param context $context the quiz context. 1495 * 1496 * @return mod_quiz_display_options 1497 */ 1498 function quiz_get_review_options($quiz, $attempt, $context) { 1499 $options = mod_quiz_display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt)); 1500 1501 $options->readonly = true; 1502 $options->flags = quiz_get_flag_option($attempt, $context); 1503 if (!empty($attempt->id)) { 1504 $options->questionreviewlink = new moodle_url('/mod/quiz/reviewquestion.php', 1505 array('attempt' => $attempt->id)); 1506 } 1507 1508 // Show a link to the comment box only for closed attempts. 1509 if (!empty($attempt->id) && $attempt->state == quiz_attempt::FINISHED && !$attempt->preview && 1510 !is_null($context) && has_capability('mod/quiz:grade', $context)) { 1511 $options->manualcomment = question_display_options::VISIBLE; 1512 $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php', 1513 array('attempt' => $attempt->id)); 1514 } 1515 1516 if (!is_null($context) && !$attempt->preview && 1517 has_capability('mod/quiz:viewreports', $context) && 1518 has_capability('moodle/grade:viewhidden', $context)) { 1519 // People who can see reports and hidden grades should be shown everything, 1520 // except during preview when teachers want to see what students see. 1521 $options->attempt = question_display_options::VISIBLE; 1522 $options->correctness = question_display_options::VISIBLE; 1523 $options->marks = question_display_options::MARK_AND_MAX; 1524 $options->feedback = question_display_options::VISIBLE; 1525 $options->numpartscorrect = question_display_options::VISIBLE; 1526 $options->manualcomment = question_display_options::VISIBLE; 1527 $options->generalfeedback = question_display_options::VISIBLE; 1528 $options->rightanswer = question_display_options::VISIBLE; 1529 $options->overallfeedback = question_display_options::VISIBLE; 1530 $options->history = question_display_options::VISIBLE; 1531 $options->userinfoinhistory = $attempt->userid; 1532 1533 } 1534 1535 return $options; 1536 } 1537 1538 /** 1539 * Combines the review options from a number of different quiz attempts. 1540 * Returns an array of two ojects, so the suggested way of calling this 1541 * funciton is: 1542 * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...) 1543 * 1544 * @param object $quiz the quiz instance. 1545 * @param array $attempts an array of attempt objects. 1546 * 1547 * @return array of two options objects, one showing which options are true for 1548 * at least one of the attempts, the other showing which options are true 1549 * for all attempts. 1550 */ 1551 function quiz_get_combined_reviewoptions($quiz, $attempts) { 1552 $fields = array('feedback', 'generalfeedback', 'rightanswer', 'overallfeedback'); 1553 $someoptions = new stdClass(); 1554 $alloptions = new stdClass(); 1555 foreach ($fields as $field) { 1556 $someoptions->$field = false; 1557 $alloptions->$field = true; 1558 } 1559 $someoptions->marks = question_display_options::HIDDEN; 1560 $alloptions->marks = question_display_options::MARK_AND_MAX; 1561 1562 // This shouldn't happen, but we need to prevent reveal information. 1563 if (empty($attempts)) { 1564 return array($someoptions, $someoptions); 1565 } 1566 1567 foreach ($attempts as $attempt) { 1568 $attemptoptions = mod_quiz_display_options::make_from_quiz($quiz, 1569 quiz_attempt_state($quiz, $attempt)); 1570 foreach ($fields as $field) { 1571 $someoptions->$field = $someoptions->$field || $attemptoptions->$field; 1572 $alloptions->$field = $alloptions->$field && $attemptoptions->$field; 1573 } 1574 $someoptions->marks = max($someoptions->marks, $attemptoptions->marks); 1575 $alloptions->marks = min($alloptions->marks, $attemptoptions->marks); 1576 } 1577 return array($someoptions, $alloptions); 1578 } 1579 1580 // Functions for sending notification messages ///////////////////////////////// 1581 1582 /** 1583 * Sends a confirmation message to the student confirming that the attempt was processed. 1584 * 1585 * @param object $a lots of useful information that can be used in the message 1586 * subject and body. 1587 * 1588 * @return int|false as for {@link message_send()}. 1589 */ 1590 function quiz_send_confirmation($recipient, $a) { 1591 1592 // Add information about the recipient to $a. 1593 // Don't do idnumber. we want idnumber to be the submitter's idnumber. 1594 $a->username = fullname($recipient); 1595 $a->userusername = $recipient->username; 1596 1597 // Prepare the message. 1598 $eventdata = new \core\message\message(); 1599 $eventdata->courseid = $a->courseid; 1600 $eventdata->component = 'mod_quiz'; 1601 $eventdata->name = 'confirmation'; 1602 $eventdata->notification = 1; 1603 1604 $eventdata->userfrom = core_user::get_noreply_user(); 1605 $eventdata->userto = $recipient; 1606 $eventdata->subject = get_string('emailconfirmsubject', 'quiz', $a); 1607 $eventdata->fullmessage = get_string('emailconfirmbody', 'quiz', $a); 1608 $eventdata->fullmessageformat = FORMAT_PLAIN; 1609 $eventdata->fullmessagehtml = ''; 1610 1611 $eventdata->smallmessage = get_string('emailconfirmsmall', 'quiz', $a); 1612 $eventdata->contexturl = $a->quizurl; 1613 $eventdata->contexturlname = $a->quizname; 1614 $eventdata->customdata = [ 1615 'cmid' => $a->quizcmid, 1616 'instance' => $a->quizid, 1617 'attemptid' => $a->attemptid, 1618 ]; 1619 1620 // ... and send it. 1621 return message_send($eventdata); 1622 } 1623 1624 /** 1625 * Sends notification messages to the interested parties that assign the role capability 1626 * 1627 * @param object $recipient user object of the intended recipient 1628 * @param object $a associative array of replaceable fields for the templates 1629 * 1630 * @return int|false as for {@link message_send()}. 1631 */ 1632 function quiz_send_notification($recipient, $submitter, $a) { 1633 global $PAGE; 1634 1635 // Recipient info for template. 1636 $a->useridnumber = $recipient->idnumber; 1637 $a->username = fullname($recipient); 1638 $a->userusername = $recipient->username; 1639 1640 // Prepare the message. 1641 $eventdata = new \core\message\message(); 1642 $eventdata->courseid = $a->courseid; 1643 $eventdata->component = 'mod_quiz'; 1644 $eventdata->name = 'submission'; 1645 $eventdata->notification = 1; 1646 1647 $eventdata->userfrom = $submitter; 1648 $eventdata->userto = $recipient; 1649 $eventdata->subject = get_string('emailnotifysubject', 'quiz', $a); 1650 $eventdata->fullmessage = get_string('emailnotifybody', 'quiz', $a); 1651 $eventdata->fullmessageformat = FORMAT_PLAIN; 1652 $eventdata->fullmessagehtml = ''; 1653 1654 $eventdata->smallmessage = get_string('emailnotifysmall', 'quiz', $a); 1655 $eventdata->contexturl = $a->quizreviewurl; 1656 $eventdata->contexturlname = $a->quizname; 1657 $userpicture = new user_picture($submitter); 1658 $userpicture->size = 1; // Use f1 size. 1659 $userpicture->includetoken = $recipient->id; // Generate an out-of-session token for the user receiving the message. 1660 $eventdata->customdata = [ 1661 'cmid' => $a->quizcmid, 1662 'instance' => $a->quizid, 1663 'attemptid' => $a->attemptid, 1664 'notificationiconurl' => $userpicture->get_url($PAGE)->out(false), 1665 ]; 1666 1667 // ... and send it. 1668 return message_send($eventdata); 1669 } 1670 1671 /** 1672 * Send all the requried messages when a quiz attempt is submitted. 1673 * 1674 * @param object $course the course 1675 * @param object $quiz the quiz 1676 * @param object $attempt this attempt just finished 1677 * @param object $context the quiz context 1678 * @param object $cm the coursemodule for this quiz 1679 * 1680 * @return bool true if all necessary messages were sent successfully, else false. 1681 */ 1682 function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm) { 1683 global $CFG, $DB; 1684 1685 // Do nothing if required objects not present. 1686 if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) { 1687 throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.'); 1688 } 1689 1690 $submitter = $DB->get_record('user', array('id' => $attempt->userid), '*', MUST_EXIST); 1691 1692 // Check for confirmation required. 1693 $sendconfirm = false; 1694 $notifyexcludeusers = ''; 1695 if (has_capability('mod/quiz:emailconfirmsubmission', $context, $submitter, false)) { 1696 $notifyexcludeusers = $submitter->id; 1697 $sendconfirm = true; 1698 } 1699 1700 // Check for notifications required. 1701 $notifyfields = 'u.id, u.username, u.idnumber, u.email, u.emailstop, u.lang, 1702 u.timezone, u.mailformat, u.maildisplay, u.auth, u.suspended, u.deleted, '; 1703 $userfieldsapi = \core_user\fields::for_name(); 1704 $notifyfields .= $userfieldsapi->get_sql('u', false, '', '', false)->selects; 1705 $groups = groups_get_all_groups($course->id, $submitter->id, $cm->groupingid); 1706 if (is_array($groups) && count($groups) > 0) { 1707 $groups = array_keys($groups); 1708 } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) { 1709 // If the user is not in a group, and the quiz is set to group mode, 1710 // then set $groups to a non-existant id so that only users with 1711 // 'moodle/site:accessallgroups' get notified. 1712 $groups = -1; 1713 } else { 1714 $groups = ''; 1715 } 1716 $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission', 1717 $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true); 1718 1719 if (empty($userstonotify) && !$sendconfirm) { 1720 return true; // Nothing to do. 1721 } 1722 1723 $a = new stdClass(); 1724 // Course info. 1725 $a->courseid = $course->id; 1726 $a->coursename = $course->fullname; 1727 $a->courseshortname = $course->shortname; 1728 // Quiz info. 1729 $a->quizname = $quiz->name; 1730 $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id; 1731 $a->quizreportlink = '<a href="' . $a->quizreporturl . '">' . 1732 format_string($quiz->name) . ' report</a>'; 1733 $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; 1734 $a->quizlink = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>'; 1735 $a->quizid = $quiz->id; 1736 $a->quizcmid = $cm->id; 1737 // Attempt info. 1738 $a->submissiontime = userdate($attempt->timefinish); 1739 $a->timetaken = format_time($attempt->timefinish - $attempt->timestart); 1740 $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id; 1741 $a->quizreviewlink = '<a href="' . $a->quizreviewurl . '">' . 1742 format_string($quiz->name) . ' review</a>'; 1743 $a->attemptid = $attempt->id; 1744 // Student who sat the quiz info. 1745 $a->studentidnumber = $submitter->idnumber; 1746 $a->studentname = fullname($submitter); 1747 $a->studentusername = $submitter->username; 1748 1749 $allok = true; 1750 1751 // Send notifications if required. 1752 if (!empty($userstonotify)) { 1753 foreach ($userstonotify as $recipient) { 1754 $allok = $allok && quiz_send_notification($recipient, $submitter, $a); 1755 } 1756 } 1757 1758 // Send confirmation if required. We send the student confirmation last, so 1759 // that if message sending is being intermittently buggy, which means we send 1760 // some but not all messages, and then try again later, then teachers may get 1761 // duplicate messages, but the student will always get exactly one. 1762 if ($sendconfirm) { 1763 $allok = $allok && quiz_send_confirmation($submitter, $a); 1764 } 1765 1766 return $allok; 1767 } 1768 1769 /** 1770 * Send the notification message when a quiz attempt becomes overdue. 1771 * 1772 * @param quiz_attempt $attemptobj all the data about the quiz attempt. 1773 */ 1774 function quiz_send_overdue_message($attemptobj) { 1775 global $CFG, $DB; 1776 1777 $submitter = $DB->get_record('user', array('id' => $attemptobj->get_userid()), '*', MUST_EXIST); 1778 1779 if (!$attemptobj->has_capability('mod/quiz:emailwarnoverdue', $submitter->id, false)) { 1780 return; // Message not required. 1781 } 1782 1783 if (!$attemptobj->has_response_to_at_least_one_graded_question()) { 1784 return; // Message not required. 1785 } 1786 1787 // Prepare lots of useful information that admins might want to include in 1788 // the email message. 1789 $quizname = format_string($attemptobj->get_quiz_name()); 1790 1791 $deadlines = array(); 1792 if ($attemptobj->get_quiz()->timelimit) { 1793 $deadlines[] = $attemptobj->get_attempt()->timestart + $attemptobj->get_quiz()->timelimit; 1794 } 1795 if ($attemptobj->get_quiz()->timeclose) { 1796 $deadlines[] = $attemptobj->get_quiz()->timeclose; 1797 } 1798 $duedate = min($deadlines); 1799 $graceend = $duedate + $attemptobj->get_quiz()->graceperiod; 1800 1801 $a = new stdClass(); 1802 // Course info. 1803 $a->courseid = $attemptobj->get_course()->id; 1804 $a->coursename = format_string($attemptobj->get_course()->fullname); 1805 $a->courseshortname = format_string($attemptobj->get_course()->shortname); 1806 // Quiz info. 1807 $a->quizname = $quizname; 1808 $a->quizurl = $attemptobj->view_url(); 1809 $a->quizlink = '<a href="' . $a->quizurl . '">' . $quizname . '</a>'; 1810 // Attempt info. 1811 $a->attemptduedate = userdate($duedate); 1812 $a->attemptgraceend = userdate($graceend); 1813 $a->attemptsummaryurl = $attemptobj->summary_url()->out(false); 1814 $a->attemptsummarylink = '<a href="' . $a->attemptsummaryurl . '">' . $quizname . ' review</a>'; 1815 // Student's info. 1816 $a->studentidnumber = $submitter->idnumber; 1817 $a->studentname = fullname($submitter); 1818 $a->studentusername = $submitter->username; 1819 1820 // Prepare the message. 1821 $eventdata = new \core\message\message(); 1822 $eventdata->courseid = $a->courseid; 1823 $eventdata->component = 'mod_quiz'; 1824 $eventdata->name = 'attempt_overdue'; 1825 $eventdata->notification = 1; 1826 1827 $eventdata->userfrom = core_user::get_noreply_user(); 1828 $eventdata->userto = $submitter; 1829 $eventdata->subject = get_string('emailoverduesubject', 'quiz', $a); 1830 $eventdata->fullmessage = get_string('emailoverduebody', 'quiz', $a); 1831 $eventdata->fullmessageformat = FORMAT_PLAIN; 1832 $eventdata->fullmessagehtml = ''; 1833 1834 $eventdata->smallmessage = get_string('emailoverduesmall', 'quiz', $a); 1835 $eventdata->contexturl = $a->quizurl; 1836 $eventdata->contexturlname = $a->quizname; 1837 $eventdata->customdata = [ 1838 'cmid' => $attemptobj->get_cmid(), 1839 'instance' => $attemptobj->get_quizid(), 1840 'attemptid' => $attemptobj->get_attemptid(), 1841 ]; 1842 1843 // Send the message. 1844 return message_send($eventdata); 1845 } 1846 1847 /** 1848 * Handle the quiz_attempt_submitted event. 1849 * 1850 * This sends the confirmation and notification messages, if required. 1851 * 1852 * @param object $event the event object. 1853 */ 1854 function quiz_attempt_submitted_handler($event) { 1855 global $DB; 1856 1857 $course = $DB->get_record('course', array('id' => $event->courseid)); 1858 $attempt = $event->get_record_snapshot('quiz_attempts', $event->objectid); 1859 $quiz = $event->get_record_snapshot('quiz', $attempt->quiz); 1860 $cm = get_coursemodule_from_id('quiz', $event->get_context()->instanceid, $event->courseid); 1861 1862 if (!($course && $quiz && $cm && $attempt)) { 1863 // Something has been deleted since the event was raised. Therefore, the 1864 // event is no longer relevant. 1865 return true; 1866 } 1867 1868 // Update completion state. 1869 $completion = new completion_info($course); 1870 if ($completion->is_enabled($cm) && 1871 ($quiz->completionattemptsexhausted || $quiz->completionpass || $quiz->completionminattempts)) { 1872 $completion->update_state($cm, COMPLETION_COMPLETE, $event->userid); 1873 } 1874 return quiz_send_notification_messages($course, $quiz, $attempt, 1875 context_module::instance($cm->id), $cm); 1876 } 1877 1878 /** 1879 * Handle groups_member_added event 1880 * 1881 * @param object $event the event object. 1882 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_added()}. 1883 */ 1884 function quiz_groups_member_added_handler($event) { 1885 debugging('quiz_groups_member_added_handler() is deprecated, please use ' . 1886 '\mod_quiz\group_observers::group_member_added() instead.', DEBUG_DEVELOPER); 1887 quiz_update_open_attempts(array('userid'=>$event->userid, 'groupid'=>$event->groupid)); 1888 } 1889 1890 /** 1891 * Handle groups_member_removed event 1892 * 1893 * @param object $event the event object. 1894 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_removed()}. 1895 */ 1896 function quiz_groups_member_removed_handler($event) { 1897 debugging('quiz_groups_member_removed_handler() is deprecated, please use ' . 1898 '\mod_quiz\group_observers::group_member_removed() instead.', DEBUG_DEVELOPER); 1899 quiz_update_open_attempts(array('userid'=>$event->userid, 'groupid'=>$event->groupid)); 1900 } 1901 1902 /** 1903 * Handle groups_group_deleted event 1904 * 1905 * @param object $event the event object. 1906 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_deleted()}. 1907 */ 1908 function quiz_groups_group_deleted_handler($event) { 1909 global $DB; 1910 debugging('quiz_groups_group_deleted_handler() is deprecated, please use ' . 1911 '\mod_quiz\group_observers::group_deleted() instead.', DEBUG_DEVELOPER); 1912 quiz_process_group_deleted_in_course($event->courseid); 1913 } 1914 1915 /** 1916 * Logic to happen when a/some group(s) has/have been deleted in a course. 1917 * 1918 * @param int $courseid The course ID. 1919 * @return void 1920 */ 1921 function quiz_process_group_deleted_in_course($courseid) { 1922 global $DB; 1923 1924 // It would be nice if we got the groupid that was deleted. 1925 // Instead, we just update all quizzes with orphaned group overrides. 1926 $sql = "SELECT o.id, o.quiz, o.groupid 1927 FROM {quiz_overrides} o 1928 JOIN {quiz} quiz ON quiz.id = o.quiz 1929 LEFT JOIN {groups} grp ON grp.id = o.groupid 1930 WHERE quiz.course = :courseid 1931 AND o.groupid IS NOT NULL 1932 AND grp.id IS NULL"; 1933 $params = array('courseid' => $courseid); 1934 $records = $DB->get_records_sql($sql, $params); 1935 if (!$records) { 1936 return; // Nothing to do. 1937 } 1938 $DB->delete_records_list('quiz_overrides', 'id', array_keys($records)); 1939 $cache = cache::make('mod_quiz', 'overrides'); 1940 foreach ($records as $record) { 1941 $cache->delete("{$record->quiz}_g_{$record->groupid}"); 1942 } 1943 quiz_update_open_attempts(['quizid' => array_unique(array_column($records, 'quiz'))]); 1944 } 1945 1946 /** 1947 * Handle groups_members_removed event 1948 * 1949 * @param object $event the event object. 1950 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_removed()}. 1951 */ 1952 function quiz_groups_members_removed_handler($event) { 1953 debugging('quiz_groups_members_removed_handler() is deprecated, please use ' . 1954 '\mod_quiz\group_observers::group_member_removed() instead.', DEBUG_DEVELOPER); 1955 if ($event->userid == 0) { 1956 quiz_update_open_attempts(array('courseid'=>$event->courseid)); 1957 } else { 1958 quiz_update_open_attempts(array('courseid'=>$event->courseid, 'userid'=>$event->userid)); 1959 } 1960 } 1961 1962 /** 1963 * Get the information about the standard quiz JavaScript module. 1964 * @return array a standard jsmodule structure. 1965 */ 1966 function quiz_get_js_module() { 1967 global $PAGE; 1968 1969 return array( 1970 'name' => 'mod_quiz', 1971 'fullpath' => '/mod/quiz/module.js', 1972 'requires' => array('base', 'dom', 'event-delegate', 'event-key', 1973 'core_question_engine', 'moodle-core-formchangechecker'), 1974 'strings' => array( 1975 array('cancel', 'moodle'), 1976 array('flagged', 'question'), 1977 array('functiondisabledbysecuremode', 'quiz'), 1978 array('startattempt', 'quiz'), 1979 array('timesup', 'quiz'), 1980 array('changesmadereallygoaway', 'moodle'), 1981 ), 1982 ); 1983 } 1984 1985 1986 /** 1987 * An extension of question_display_options that includes the extra options used 1988 * by the quiz. 1989 * 1990 * @copyright 2010 The Open University 1991 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1992 */ 1993 class mod_quiz_display_options extends question_display_options { 1994 /**#@+ 1995 * @var integer bits used to indicate various times in relation to a 1996 * quiz attempt. 1997 */ 1998 const DURING = 0x10000; 1999 const IMMEDIATELY_AFTER = 0x01000; 2000 const LATER_WHILE_OPEN = 0x00100; 2001 const AFTER_CLOSE = 0x00010; 2002 /**#@-*/ 2003 2004 /** 2005 * @var boolean if this is false, then the student is not allowed to review 2006 * anything about the attempt. 2007 */ 2008 public $attempt = true; 2009 2010 /** 2011 * @var boolean if this is false, then the student is not allowed to review 2012 * anything about the attempt. 2013 */ 2014 public $overallfeedback = self::VISIBLE; 2015 2016 /** 2017 * Set up the various options from the quiz settings, and a time constant. 2018 * @param object $quiz the quiz settings. 2019 * @param int $one of the {@link DURING}, {@link IMMEDIATELY_AFTER}, 2020 * {@link LATER_WHILE_OPEN} or {@link AFTER_CLOSE} constants. 2021 * @return mod_quiz_display_options set up appropriately. 2022 */ 2023 public static function make_from_quiz($quiz, $when) { 2024 $options = new self(); 2025 2026 $options->attempt = self::extract($quiz->reviewattempt, $when, true, false); 2027 $options->correctness = self::extract($quiz->reviewcorrectness, $when); 2028 $options->marks = self::extract($quiz->reviewmarks, $when, 2029 self::MARK_AND_MAX, self::MAX_ONLY); 2030 $options->feedback = self::extract($quiz->reviewspecificfeedback, $when); 2031 $options->generalfeedback = self::extract($quiz->reviewgeneralfeedback, $when); 2032 $options->rightanswer = self::extract($quiz->reviewrightanswer, $when); 2033 $options->overallfeedback = self::extract($quiz->reviewoverallfeedback, $when); 2034 2035 $options->numpartscorrect = $options->feedback; 2036 $options->manualcomment = $options->feedback; 2037 2038 if ($quiz->questiondecimalpoints != -1) { 2039 $options->markdp = $quiz->questiondecimalpoints; 2040 } else { 2041 $options->markdp = $quiz->decimalpoints; 2042 } 2043 2044 return $options; 2045 } 2046 2047 protected static function extract($bitmask, $bit, 2048 $whenset = self::VISIBLE, $whennotset = self::HIDDEN) { 2049 if ($bitmask & $bit) { 2050 return $whenset; 2051 } else { 2052 return $whennotset; 2053 } 2054 } 2055 } 2056 2057 /** 2058 * A {@link qubaid_condition} for finding all the question usages belonging to 2059 * a particular quiz. 2060 * 2061 * @copyright 2010 The Open University 2062 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 2063 */ 2064 class qubaids_for_quiz extends qubaid_join { 2065 public function __construct($quizid, $includepreviews = true, $onlyfinished = false) { 2066 $where = 'quiza.quiz = :quizaquiz'; 2067 $params = array('quizaquiz' => $quizid); 2068 2069 if (!$includepreviews) { 2070 $where .= ' AND preview = 0'; 2071 } 2072 2073 if ($onlyfinished) { 2074 $where .= ' AND state = :statefinished'; 2075 $params['statefinished'] = quiz_attempt::FINISHED; 2076 } 2077 2078 parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); 2079 } 2080 } 2081 2082 /** 2083 * A {@link qubaid_condition} for finding all the question usages belonging to a particular user and quiz combination. 2084 * 2085 * @copyright 2018 Andrew Nicols <andrwe@nicols.co.uk> 2086 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 2087 */ 2088 class qubaids_for_quiz_user extends qubaid_join { 2089 /** 2090 * Constructor for this qubaid. 2091 * 2092 * @param int $quizid The quiz to search. 2093 * @param int $userid The user to filter on 2094 * @param bool $includepreviews Whether to include preview attempts 2095 * @param bool $onlyfinished Whether to only include finished attempts or not 2096 */ 2097 public function __construct($quizid, $userid, $includepreviews = true, $onlyfinished = false) { 2098 $where = 'quiza.quiz = :quizaquiz AND quiza.userid = :quizauserid'; 2099 $params = [ 2100 'quizaquiz' => $quizid, 2101 'quizauserid' => $userid, 2102 ]; 2103 2104 if (!$includepreviews) { 2105 $where .= ' AND preview = 0'; 2106 } 2107 2108 if ($onlyfinished) { 2109 $where .= ' AND state = :statefinished'; 2110 $params['statefinished'] = quiz_attempt::FINISHED; 2111 } 2112 2113 parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); 2114 } 2115 } 2116 2117 /** 2118 * Creates a textual representation of a question for display. 2119 * 2120 * @param object $question A question object from the database questions table 2121 * @param bool $showicon If true, show the question's icon with the question. False by default. 2122 * @param bool $showquestiontext If true (default), show question text after question name. 2123 * If false, show only question name. 2124 * @param bool $showidnumber If true, show the question's idnumber, if any. False by default. 2125 * @param core_tag_tag[]|bool $showtags if array passed, show those tags. Else, if true, get and show tags, 2126 * else, don't show tags (which is the default). 2127 * @return string HTML fragment. 2128 */ 2129 function quiz_question_tostring($question, $showicon = false, $showquestiontext = true, 2130 $showidnumber = false, $showtags = false) { 2131 global $OUTPUT; 2132 $result = ''; 2133 2134 // Question name. 2135 $name = shorten_text(format_string($question->name), 200); 2136 if ($showicon) { 2137 $name .= print_question_icon($question) . ' ' . $name; 2138 } 2139 $result .= html_writer::span($name, 'questionname'); 2140 2141 // Question idnumber. 2142 if ($showidnumber && $question->idnumber !== null && $question->idnumber !== '') { 2143 $result .= ' ' . html_writer::span( 2144 html_writer::span(get_string('idnumber', 'question'), 'accesshide') . 2145 ' ' . s($question->idnumber), 'badge badge-primary'); 2146 } 2147 2148 // Question tags. 2149 if (is_array($showtags)) { 2150 $tags = $showtags; 2151 } else if ($showtags) { 2152 $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); 2153 } else { 2154 $tags = []; 2155 } 2156 if ($tags) { 2157 $result .= $OUTPUT->tag_list($tags, null, 'd-inline', 0, null, true); 2158 } 2159 2160 // Question text. 2161 if ($showquestiontext) { 2162 $questiontext = question_utils::to_plain_text($question->questiontext, 2163 $question->questiontextformat, array('noclean' => true, 'para' => false)); 2164 $questiontext = shorten_text($questiontext, 200); 2165 if ($questiontext) { 2166 $result .= ' ' . html_writer::span(s($questiontext), 'questiontext'); 2167 } 2168 } 2169 2170 return $result; 2171 } 2172 2173 /** 2174 * Verify that the question exists, and the user has permission to use it. 2175 * Does not return. Throws an exception if the question cannot be used. 2176 * @param int $questionid The id of the question. 2177 */ 2178 function quiz_require_question_use($questionid) { 2179 global $DB; 2180 $question = $DB->get_record('question', array('id' => $questionid), '*', MUST_EXIST); 2181 question_require_capability_on($question, 'use'); 2182 } 2183 2184 /** 2185 * Verify that the question exists, and the user has permission to use it. 2186 * @param object $quiz the quiz settings. 2187 * @param int $slot which question in the quiz to test. 2188 * @return bool whether the user can use this question. 2189 */ 2190 function quiz_has_question_use($quiz, $slot) { 2191 global $DB; 2192 $question = $DB->get_record_sql(" 2193 SELECT q.* 2194 FROM {quiz_slots} slot 2195 JOIN {question} q ON q.id = slot.questionid 2196 WHERE slot.quizid = ? AND slot.slot = ?", array($quiz->id, $slot)); 2197 if (!$question) { 2198 return false; 2199 } 2200 return question_has_capability_on($question, 'use'); 2201 } 2202 2203 /** 2204 * Add a question to a quiz 2205 * 2206 * Adds a question to a quiz by updating $quiz as well as the 2207 * quiz and quiz_slots tables. It also adds a page break if required. 2208 * @param int $questionid The id of the question to be added 2209 * @param object $quiz The extended quiz object as used by edit.php 2210 * This is updated by this function 2211 * @param int $page Which page in quiz to add the question on. If 0 (default), 2212 * add at the end 2213 * @param float $maxmark The maximum mark to set for this question. (Optional, 2214 * defaults to question.defaultmark. 2215 * @return bool false if the question was already in the quiz 2216 */ 2217 function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) { 2218 global $DB; 2219 2220 // Make sue the question is not of the "random" type. 2221 $questiontype = $DB->get_field('question', 'qtype', array('id' => $questionid)); 2222 if ($questiontype == 'random') { 2223 throw new coding_exception( 2224 'Adding "random" questions via quiz_add_quiz_question() is deprecated. Please use quiz_add_random_questions().' 2225 ); 2226 } 2227 2228 $trans = $DB->start_delegated_transaction(); 2229 $slots = $DB->get_records('quiz_slots', array('quizid' => $quiz->id), 2230 'slot', 'questionid, slot, page, id'); 2231 if (array_key_exists($questionid, $slots)) { 2232 $trans->allow_commit(); 2233 return false; 2234 } 2235 2236 $maxpage = 1; 2237 $numonlastpage = 0; 2238 foreach ($slots as $slot) { 2239 if ($slot->page > $maxpage) { 2240 $maxpage = $slot->page; 2241 $numonlastpage = 1; 2242 } else { 2243 $numonlastpage += 1; 2244 } 2245 } 2246 2247 // Add the new question instance. 2248 $slot = new stdClass(); 2249 $slot->quizid = $quiz->id; 2250 $slot->questionid = $questionid; 2251 2252 if ($maxmark !== null) { 2253 $slot->maxmark = $maxmark; 2254 } else { 2255 $slot->maxmark = $DB->get_field('question', 'defaultmark', array('id' => $questionid)); 2256 } 2257 2258 if (is_int($page) && $page >= 1) { 2259 // Adding on a given page. 2260 $lastslotbefore = 0; 2261 foreach (array_reverse($slots) as $otherslot) { 2262 if ($otherslot->page > $page) { 2263 $DB->set_field('quiz_slots', 'slot', $otherslot->slot + 1, array('id' => $otherslot->id)); 2264 } else { 2265 $lastslotbefore = $otherslot->slot; 2266 break; 2267 } 2268 } 2269 $slot->slot = $lastslotbefore + 1; 2270 $slot->page = min($page, $maxpage + 1); 2271 2272 quiz_update_section_firstslots($quiz->id, 1, max($lastslotbefore, 1)); 2273 2274 } else { 2275 $lastslot = end($slots); 2276 if ($lastslot) { 2277 $slot->slot = $lastslot->slot + 1; 2278 } else { 2279 $slot->slot = 1; 2280 } 2281 if ($quiz->questionsperpage && $numonlastpage >= $quiz->questionsperpage) { 2282 $slot->page = $maxpage + 1; 2283 } else { 2284 $slot->page = $maxpage; 2285 } 2286 } 2287 2288 $DB->insert_record('quiz_slots', $slot); 2289 $trans->allow_commit(); 2290 } 2291 2292 /** 2293 * Move all the section headings in a certain slot range by a certain offset. 2294 * 2295 * @param int $quizid the id of a quiz 2296 * @param int $direction amount to adjust section heading positions. Normally +1 or -1. 2297 * @param int $afterslot adjust headings that start after this slot. 2298 * @param int|null $beforeslot optionally, only adjust headings before this slot. 2299 */ 2300 function quiz_update_section_firstslots($quizid, $direction, $afterslot, $beforeslot = null) { 2301 global $DB; 2302 $where = 'quizid = ? AND firstslot > ?'; 2303 $params = [$direction, $quizid, $afterslot]; 2304 if ($beforeslot) { 2305 $where .= ' AND firstslot < ?'; 2306 $params[] = $beforeslot; 2307 } 2308 $firstslotschanges = $DB->get_records_select_menu('quiz_sections', 2309 $where, $params, '', 'firstslot, firstslot + ?'); 2310 update_field_with_unique_index('quiz_sections', 'firstslot', $firstslotschanges, ['quizid' => $quizid]); 2311 } 2312 2313 /** 2314 * Add a random question to the quiz at a given point. 2315 * @param stdClass $quiz the quiz settings. 2316 * @param int $addonpage the page on which to add the question. 2317 * @param int $categoryid the question category to add the question from. 2318 * @param int $number the number of random questions to add. 2319 * @param bool $includesubcategories whether to include questoins from subcategories. 2320 * @param int[] $tagids Array of tagids. The question that will be picked randomly should be tagged with all these tags. 2321 */ 2322 function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number, 2323 $includesubcategories, $tagids = []) { 2324 global $DB; 2325 2326 $category = $DB->get_record('question_categories', array('id' => $categoryid)); 2327 if (!$category) { 2328 print_error('invalidcategoryid', 'error'); 2329 } 2330 2331 $catcontext = context::instance_by_id($category->contextid); 2332 require_capability('moodle/question:useall', $catcontext); 2333 2334 $tags = \core_tag_tag::get_bulk($tagids, 'id, name'); 2335 $tagstrings = []; 2336 foreach ($tags as $tag) { 2337 $tagstrings[] = "{$tag->id},{$tag->name}"; 2338 } 2339 2340 // Find existing random questions in this category that are 2341 // not used by any quiz. 2342 $existingquestions = $DB->get_records_sql( 2343 "SELECT q.id, q.qtype FROM {question} q 2344 WHERE qtype = 'random' 2345 AND category = ? 2346 AND " . $DB->sql_compare_text('questiontext') . " = ? 2347 AND NOT EXISTS ( 2348 SELECT * 2349 FROM {quiz_slots} 2350 WHERE questionid = q.id) 2351 ORDER BY id", array($category->id, $includesubcategories ? '1' : '0')); 2352 2353 for ($i = 0; $i < $number; $i++) { 2354 // Take as many of orphaned "random" questions as needed. 2355 if (!$question = array_shift($existingquestions)) { 2356 $form = new stdClass(); 2357 $form->category = $category->id . ',' . $category->contextid; 2358 $form->includesubcategories = $includesubcategories; 2359 $form->fromtags = $tagstrings; 2360 $form->defaultmark = 1; 2361 $form->hidden = 1; 2362 $form->stamp = make_unique_id_code(); // Set the unique code (not to be changed). 2363 $question = new stdClass(); 2364 $question->qtype = 'random'; 2365 $question = question_bank::get_qtype('random')->save_question($question, $form); 2366 if (!isset($question->id)) { 2367 print_error('cannotinsertrandomquestion', 'quiz'); 2368 } 2369 } 2370 2371 $randomslotdata = new stdClass(); 2372 $randomslotdata->quizid = $quiz->id; 2373 $randomslotdata->questionid = $question->id; 2374 $randomslotdata->questioncategoryid = $categoryid; 2375 $randomslotdata->includingsubcategories = $includesubcategories ? 1 : 0; 2376 $randomslotdata->maxmark = 1; 2377 2378 $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); 2379 $randomslot->set_quiz($quiz); 2380 $randomslot->set_tags($tags); 2381 $randomslot->insert($addonpage); 2382 } 2383 } 2384 2385 /** 2386 * Mark the activity completed (if required) and trigger the course_module_viewed event. 2387 * 2388 * @param stdClass $quiz quiz object 2389 * @param stdClass $course course object 2390 * @param stdClass $cm course module object 2391 * @param stdClass $context context object 2392 * @since Moodle 3.1 2393 */ 2394 function quiz_view($quiz, $course, $cm, $context) { 2395 2396 $params = array( 2397 'objectid' => $quiz->id, 2398 'context' => $context 2399 ); 2400 2401 $event = \mod_quiz\event\course_module_viewed::create($params); 2402 $event->add_record_snapshot('quiz', $quiz); 2403 $event->trigger(); 2404 2405 // Completion. 2406 $completion = new completion_info($course); 2407 $completion->set_module_viewed($cm); 2408 } 2409 2410 /** 2411 * Validate permissions for creating a new attempt and start a new preview attempt if required. 2412 * 2413 * @param quiz $quizobj quiz object 2414 * @param quiz_access_manager $accessmanager quiz access manager 2415 * @param bool $forcenew whether was required to start a new preview attempt 2416 * @param int $page page to jump to in the attempt 2417 * @param bool $redirect whether to redirect or throw exceptions (for web or ws usage) 2418 * @return array an array containing the attempt information, access error messages and the page to jump to in the attempt 2419 * @throws moodle_quiz_exception 2420 * @since Moodle 3.1 2421 */ 2422 function quiz_validate_new_attempt(quiz $quizobj, quiz_access_manager $accessmanager, $forcenew, $page, $redirect) { 2423 global $DB, $USER; 2424 $timenow = time(); 2425 2426 if ($quizobj->is_preview_user() && $forcenew) { 2427 $accessmanager->current_attempt_finished(); 2428 } 2429 2430 // Check capabilities. 2431 if (!$quizobj->is_preview_user()) { 2432 $quizobj->require_capability('mod/quiz:attempt'); 2433 } 2434 2435 // Check to see if a new preview was requested. 2436 if ($quizobj->is_preview_user() && $forcenew) { 2437 // To force the creation of a new preview, we mark the current attempt (if any) 2438 // as abandoned. It will then automatically be deleted below. 2439 $DB->set_field('quiz_attempts', 'state', quiz_attempt::ABANDONED, 2440 array('quiz' => $quizobj->get_quizid(), 'userid' => $USER->id)); 2441 } 2442 2443 // Look for an existing attempt. 2444 $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $USER->id, 'all', true); 2445 $lastattempt = end($attempts); 2446 2447 $attemptnumber = null; 2448 // If an in-progress attempt exists, check password then redirect to it. 2449 if ($lastattempt && ($lastattempt->state == quiz_attempt::IN_PROGRESS || 2450 $lastattempt->state == quiz_attempt::OVERDUE)) { 2451 $currentattemptid = $lastattempt->id; 2452 $messages = $accessmanager->prevent_access(); 2453 2454 // If the attempt is now overdue, deal with that. 2455 $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow, true); 2456 2457 // And, if the attempt is now no longer in progress, redirect to the appropriate place. 2458 if ($lastattempt->state == quiz_attempt::ABANDONED || $lastattempt->state == quiz_attempt::FINISHED) { 2459 if ($redirect) { 2460 redirect($quizobj->review_url($lastattempt->id)); 2461 } else { 2462 throw new moodle_quiz_exception($quizobj, 'attemptalreadyclosed'); 2463 } 2464 } 2465 2466 // If the page number was not explicitly in the URL, go to the current page. 2467 if ($page == -1) { 2468 $page = $lastattempt->currentpage; 2469 } 2470 2471 } else { 2472 while ($lastattempt && $lastattempt->preview) { 2473 $lastattempt = array_pop($attempts); 2474 } 2475 2476 // Get number for the next or unfinished attempt. 2477 if ($lastattempt) { 2478 $attemptnumber = $lastattempt->attempt + 1; 2479 } else { 2480 $lastattempt = false; 2481 $attemptnumber = 1; 2482 } 2483 $currentattemptid = null; 2484 2485 $messages = $accessmanager->prevent_access() + 2486 $accessmanager->prevent_new_attempt(count($attempts), $lastattempt); 2487 2488 if ($page == -1) { 2489 $page = 0; 2490 } 2491 } 2492 return array($currentattemptid, $attemptnumber, $lastattempt, $messages, $page); 2493 } 2494 2495 /** 2496 * Prepare and start a new attempt deleting the previous preview attempts. 2497 * 2498 * @param quiz $quizobj quiz object 2499 * @param int $attemptnumber the attempt number 2500 * @param object $lastattempt last attempt object 2501 * @param bool $offlineattempt whether is an offline attempt or not 2502 * @param array $forcedrandomquestions slot number => question id. Used for random questions, 2503 * to force the choice of a particular actual question. Intended for testing purposes only. 2504 * @param array $forcedvariants slot number => variant. Used for questions with variants, 2505 * to force the choice of a particular variant. Intended for testing purposes only. 2506 * @param int $userid Specific user id to create an attempt for that user, null for current logged in user 2507 * @return object the new attempt 2508 * @since Moodle 3.1 2509 */ 2510 function quiz_prepare_and_start_new_attempt(quiz $quizobj, $attemptnumber, $lastattempt, 2511 $offlineattempt = false, $forcedrandomquestions = [], $forcedvariants = [], $userid = null) { 2512 global $DB, $USER; 2513 2514 if ($userid === null) { 2515 $userid = $USER->id; 2516 $ispreviewuser = $quizobj->is_preview_user(); 2517 } else { 2518 $ispreviewuser = has_capability('mod/quiz:preview', $quizobj->get_context(), $userid); 2519 } 2520 // Delete any previous preview attempts belonging to this user. 2521 quiz_delete_previews($quizobj->get_quiz(), $userid); 2522 2523 $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); 2524 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); 2525 2526 // Create the new attempt and initialize the question sessions 2527 $timenow = time(); // Update time now, in case the server is running really slowly. 2528 $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $ispreviewuser, $userid); 2529 2530 if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) { 2531 $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, 2532 $forcedrandomquestions, $forcedvariants); 2533 } else { 2534 $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt); 2535 } 2536 2537 $transaction = $DB->start_delegated_transaction(); 2538 2539 // Init the timemodifiedoffline for offline attempts. 2540 if ($offlineattempt) { 2541 $attempt->timemodifiedoffline = $attempt->timemodified; 2542 } 2543 $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt); 2544 2545 $transaction->allow_commit(); 2546 2547 return $attempt; 2548 } 2549 2550 /** 2551 * Check if the given calendar_event is either a user or group override 2552 * event for quiz. 2553 * 2554 * @param calendar_event $event The calendar event to check 2555 * @return bool 2556 */ 2557 function quiz_is_overriden_calendar_event(\calendar_event $event) { 2558 global $DB; 2559 2560 if (!isset($event->modulename)) { 2561 return false; 2562 } 2563 2564 if ($event->modulename != 'quiz') { 2565 return false; 2566 } 2567 2568 if (!isset($event->instance)) { 2569 return false; 2570 } 2571 2572 if (!isset($event->userid) && !isset($event->groupid)) { 2573 return false; 2574 } 2575 2576 $overrideparams = [ 2577 'quiz' => $event->instance 2578 ]; 2579 2580 if (isset($event->groupid)) { 2581 $overrideparams['groupid'] = $event->groupid; 2582 } else if (isset($event->userid)) { 2583 $overrideparams['userid'] = $event->userid; 2584 } 2585 2586 return $DB->record_exists('quiz_overrides', $overrideparams); 2587 } 2588 2589 /** 2590 * Retrieves tag information for the given list of quiz slot ids. 2591 * Currently the only slots that have tags are random question slots. 2592 * 2593 * Example: 2594 * If we have 3 slots with id 1, 2, and 3. The first slot has two tags, the second 2595 * has one tag, and the third has zero tags. The return structure will look like: 2596 * [ 2597 * 1 => [ 2598 * quiz_slot_tags.id => { ...tag data... }, 2599 * quiz_slot_tags.id => { ...tag data... }, 2600 * ], 2601 * 2 => [ 2602 * quiz_slot_tags.id => { ...tag data... }, 2603 * ], 2604 * 3 => [], 2605 * ] 2606 * 2607 * @param int[] $slotids The list of id for the quiz slots. 2608 * @return array[] List of quiz_slot_tags records indexed by slot id. 2609 */ 2610 function quiz_retrieve_tags_for_slot_ids($slotids) { 2611 global $DB; 2612 2613 if (empty($slotids)) { 2614 return []; 2615 } 2616 2617 $slottags = $DB->get_records_list('quiz_slot_tags', 'slotid', $slotids); 2618 $tagsbyid = core_tag_tag::get_bulk(array_filter(array_column($slottags, 'tagid')), 'id, name'); 2619 $tagsbyname = false; // It will be loaded later if required. 2620 $emptytagids = array_reduce($slotids, function($carry, $slotid) { 2621 $carry[$slotid] = []; 2622 return $carry; 2623 }, []); 2624 2625 return array_reduce( 2626 $slottags, 2627 function($carry, $slottag) use ($slottags, $tagsbyid, $tagsbyname) { 2628 if (isset($tagsbyid[$slottag->tagid])) { 2629 // Make sure that we're returning the most updated tag name. 2630 $slottag->tagname = $tagsbyid[$slottag->tagid]->name; 2631 } else { 2632 if ($tagsbyname === false) { 2633 // We were hoping that this query could be avoided, but life 2634 // showed its other side to us! 2635 $tagcollid = core_tag_area::get_collection('core', 'question'); 2636 $tagsbyname = core_tag_tag::get_by_name_bulk( 2637 $tagcollid, 2638 array_column($slottags, 'tagname'), 2639 'id, name' 2640 ); 2641 } 2642 if (isset($tagsbyname[$slottag->tagname])) { 2643 // Make sure that we're returning the current tag id that matches 2644 // the given tag name. 2645 $slottag->tagid = $tagsbyname[$slottag->tagname]->id; 2646 } else { 2647 // The tag does not exist anymore (neither the tag id nor the tag name 2648 // matches an existing tag). 2649 // We still need to include this row in the result as some callers might 2650 // be interested in these rows. An example is the editing forms that still 2651 // need to display tag names even if they don't exist anymore. 2652 $slottag->tagid = null; 2653 } 2654 } 2655 2656 $carry[$slottag->slotid][$slottag->id] = $slottag; 2657 return $carry; 2658 }, 2659 $emptytagids 2660 ); 2661 } 2662 2663 /** 2664 * Retrieves tag information for the given quiz slot. 2665 * A quiz slot have some tags if and only if it is representing a random question by tags. 2666 * 2667 * @param int $slotid The id of the quiz slot. 2668 * @return stdClass[] List of quiz_slot_tags records. 2669 */ 2670 function quiz_retrieve_slot_tags($slotid) { 2671 $slottags = quiz_retrieve_tags_for_slot_ids([$slotid]); 2672 return $slottags[$slotid]; 2673 } 2674 2675 /** 2676 * Retrieves tag ids for the given quiz slot. 2677 * A quiz slot have some tags if and only if it is representing a random question by tags. 2678 * 2679 * @param int $slotid The id of the quiz slot. 2680 * @return int[] 2681 */ 2682 function quiz_retrieve_slot_tag_ids($slotid) { 2683 $tags = quiz_retrieve_slot_tags($slotid); 2684 2685 // Only work with tags that exist. 2686 return array_filter(array_column($tags, 'tagid')); 2687 } 2688 2689 /** 2690 * Get quiz attempt and handling error. 2691 * 2692 * @param int $attemptid the id of the current attempt. 2693 * @param int|null $cmid the course_module id for this quiz. 2694 * @return quiz_attempt $attemptobj all the data about the quiz attempt. 2695 * @throws moodle_exception 2696 */ 2697 function quiz_create_attempt_handling_errors($attemptid, $cmid = null) { 2698 try { 2699 $attempobj = quiz_attempt::create($attemptid); 2700 } catch (moodle_exception $e) { 2701 if (!empty($cmid)) { 2702 list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'quiz'); 2703 $continuelink = new moodle_url('/mod/quiz/view.php', array('id' => $cmid)); 2704 $context = context_module::instance($cm->id); 2705 if (has_capability('mod/quiz:preview', $context)) { 2706 throw new moodle_exception('attempterrorcontentchange', 'quiz', $continuelink); 2707 } else { 2708 throw new moodle_exception('attempterrorcontentchangeforuser', 'quiz', $continuelink); 2709 } 2710 } else { 2711 throw new moodle_exception('attempterrorinvalid', 'quiz'); 2712 } 2713 } 2714 if (!empty($cmid) && $attempobj->get_cmid() != $cmid) { 2715 throw new moodle_exception('invalidcoursemodule'); 2716 } else { 2717 return $attempobj; 2718 } 2719 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body