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