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