MDL-16263 A way for students to flag/bookmark, particular questions during a quiz...
[moodle.git] / mod / quiz / locallib.php
CommitLineData
76cacec8 1<?php // $Id$
ee1fb969 2/**
26d12a0c 3 * Library of functions used by the quiz module.
4 *
5 * This contains functions that are called from within the quiz module only
6 * Functions that are also called by core Moodle are in {@link lib.php}
7 * This script also loads the code in {@link questionlib.php} which holds
8 * the module-indpendent code for handling questions and which in turn
9 * initialises all the questiontype classes.
f63a4ff2 10 *
26d12a0c 11 * @author Martin Dougiamas and many others. This has recently been completely
12 * rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of
13 * the Serving Mathematics project
14 * {@link http://maths.york.ac.uk/serving_maths}
15 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
16 * @package quiz
17 */
76cacec8 18
66d07f81 19if (!defined('MOODLE_INTERNAL')) {
20 die('Direct access to this script is forbidden.'); /// It must be included from a Moodle page.
21}
22
ee1fb969 23/**
26d12a0c 24 * Include those library functions that are also used by core Moodle or other modules
25 */
26require_once($CFG->dirroot . '/mod/quiz/lib.php');
c52c62d1 27require_once($CFG->dirroot . '/mod/quiz/accessrules.php');
26d12a0c 28require_once($CFG->dirroot . '/question/editlib.php');
78e7a3dd 29require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
3b120e46 30require_once($CFG->libdir . '/eventslib.php');
76cacec8 31
26d12a0c 32/// Constants ///////////////////////////////////////////////////////////////////
76cacec8 33
a4faed69 34/**#@+
35 * Constants to describe the various states a quiz attempt can be in.
36 */
c52c62d1 37define('QUIZ_STATE_DURING', 'during');
38define('QUIZ_STATE_IMMEDIATELY', 'immedately');
39define('QUIZ_STATE_OPEN', 'open');
40define('QUIZ_STATE_CLOSED', 'closed');
a4faed69 41define('QUIZ_STATE_TEACHERACCESS', 'teacheraccess'); // State only relevant if you are in a studenty role.
42/**#@-*/
43
c52c62d1 44/**
45 * We don't log every single hit on attempt.php, only significant ones like starting and
46 * ending an attempt, and periodically during the attempt, as defined by this constant. (10 mins)
47 */
48define('QUIZ_CONTINUE_ATTEMPT_LOG_INTERVAL', '600');
49
50/**
51 * We show the countdown timer if there is less than this amount of time left before the
52 * the quiz close date. (1 hour)
53 */
54define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600');
55
26d12a0c 56/// Functions related to attempts /////////////////////////////////////////
ee1fb969 57
58/**
26d12a0c 59 * Creates an object to represent a new attempt at a quiz
60 *
61 * Creates an attempt object to represent an attempt at the quiz by the current
62 * user starting at the current time. The ->id field is not set. The object is
63 * NOT written to the database.
c52c62d1 64 *
65 * @param object $quiz the quiz to create an attempt for.
66 * @param integer $attemptnumber the sequence number for the attempt.
67 * @param object $lastattempt the previous attempt by this user, if any. Only needed
68 * if $attemptnumber > 1 and $quiz->attemptonlast is true.
69 * @param integer $timenow the time the attempt was started at.
70 * @param boolean $ispreview whether this new attempt is a preview.
71 *
72 * @return object the newly created attempt object.
26d12a0c 73 */
c52c62d1 74function quiz_create_attempt($quiz, $attemptnumber, $lastattempt, $timenow, $ispreview = false) {
75 global $USER;
ee1fb969 76
e89e09d2 77 if ($attemptnumber == 1 || !$quiz->attemptonlast) {
c52c62d1 78 /// We are not building on last attempt so create a new attempt.
79 $attempt = new stdClass;
75e1df6f 80 $attempt->quiz = $quiz->id;
81 $attempt->userid = $USER->id;
82 $attempt->preview = 0;
83 if ($quiz->shufflequestions) {
84 $attempt->layout = quiz_repaginate($quiz->questions, $quiz->questionsperpage, true);
85 } else {
86 $attempt->layout = $quiz->questions;
87 }
c52c62d1 88 } else {
89 /// Build on last attempt.
90 if (empty($lastattempt)) {
5a2a5331 91 print_error('cannotfindprevattempt', 'quiz');
c52c62d1 92 }
93 $attempt = $lastattempt;
75e1df6f 94 }
95
ee1fb969 96 $attempt->attempt = $attemptnumber;
97 $attempt->sumgrades = 0.0;
ee1fb969 98 $attempt->timestart = $timenow;
99 $attempt->timefinish = 0;
100 $attempt->timemodified = $timenow;
4f48fb42 101 $attempt->uniqueid = question_new_attempt_uniqueid();
75e1df6f 102
c52c62d1 103/// If this is a preview, mark it as such.
104 if ($ispreview) {
105 $attempt->preview = 1;
106 }
107
ee1fb969 108 return $attempt;
109}
110
cd06115f 111/**
c52c62d1 112 * Returns the unfinished attempt for the given
113 * user on the given quiz, if there is one.
77ed3ba5 114 *
cd06115f 115 * @param integer $quizid the id of the quiz.
116 * @param integer $userid the id of the user.
77ed3ba5 117 *
118 * @return mixed the unfinished attempt if there is one, false if not.
cd06115f 119 */
0d156caa 120function quiz_get_user_attempt_unfinished($quizid, $userid) {
ed8bebce 121 $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true);
77ed3ba5 122 if ($attempts) {
123 return array_shift($attempts);
124 } else {
125 return false;
126 }
920fb237 127}
128
c52c62d1 129/**
130 * Returns the most recent attempt by a given user on a given quiz.
131 * May be finished, or may not.
132 *
133 * @param integer $quizid the id of the quiz.
134 * @param integer $userid the id of the user.
135 *
58ffdbb8 136 * @return mixed the attempt if there is one, false if not.
c52c62d1 137 */
138function quiz_get_latest_attempt_by_user($quizid, $userid) {
9cf4a18b 139 global $CFG, $DB;
140 $attempt = $DB->get_records_sql('SELECT qa.* FROM {quiz_attempts} qa
141 WHERE qa.quiz=? AND qa.userid= ? ORDER BY qa.timestart DESC, qa.id DESC', array($quizid, $userid), 0, 1);
58ffdbb8 142 if ($attempt) {
143 return array_shift($attempt);
144 } else {
145 return false;
146 }
c52c62d1 147}
148
149/**
dfee5a9a 150 * Load an attempt by id. You need to use this method instead of $DB->get_record, because
c52c62d1 151 * of some ancient history to do with the upgrade from Moodle 1.4 to 1.5, See the comment
152 * after CREATE TABLE `prefix_quiz_newest_states` in mod/quiz/db/mysql.php.
153 *
154 * @param integer $attemptid the id of the attempt to load.
155 */
156function quiz_load_attempt($attemptid) {
9cf4a18b 157 global $DB;
158 $attempt = $DB->get_record('quiz_attempts', array('id' => $attemptid));
c52c62d1 159
9cf4a18b 160 if (!$DB->record_exists('question_sessions', array('attemptid' => $attempt->uniqueid))) {
c52c62d1 161 /// this attempt has not yet been upgraded to the new model
162 quiz_upgrade_states($attempt);
163 }
164
165 return $attempt;
166}
167
ff51d646 168/**
169 * Delete a quiz attempt.
97344ae3 170 * @param mixed $attempt an integer attempt id or an attempt object (row of the quiz_attempts table).
171 * @param object $quiz the quiz object.
ff51d646 172 */
173function quiz_delete_attempt($attempt, $quiz) {
9cf4a18b 174 global $DB;
ff51d646 175 if (is_numeric($attempt)) {
9cf4a18b 176 if (!$attempt = $DB->get_record('quiz_attempts', array('id' => $attempt))) {
ff51d646 177 return;
178 }
179 }
271e6dec 180
ff51d646 181 if ($attempt->quiz != $quiz->id) {
182 debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " .
183 "but was passed quiz $quiz->id.");
184 return;
185 }
271e6dec 186
9cf4a18b 187 $DB->delete_records('quiz_attempts', array('id' => $attempt->id));
ff51d646 188 delete_attempt($attempt->uniqueid);
189
190 // Search quiz_attempts for other instances by this user.
191 // If none, then delete record for this quiz, this user from quiz_grades
192 // else recalculate best grade
193
194 $userid = $attempt->userid;
9cf4a18b 195 if (!$DB->record_exists('quiz_attempts', array('userid' => $userid, 'quiz' => $quiz->id))) {
196 $DB->delete_records('quiz_grades', array('userid' => $userid,'quiz' => $quiz->id));
ff51d646 197 } else {
198 quiz_save_best_grade($quiz, $userid);
199 }
07a7d859 200
201 quiz_update_grades($quiz, $userid);
ff51d646 202}
203
26d12a0c 204/// Functions to do with quiz layout and pages ////////////////////////////////
0d156caa 205
ee1fb969 206/**
26d12a0c 207 * Returns a comma separated list of question ids for the current page
208 *
209 * @return string Comma separated list of question ids
210 * @param string $layout The string representing the quiz layout. Each page is represented as a
211 * comma separated list of question ids and 0 indicating page breaks.
212 * So 5,2,0,3,0 means questions 5 and 2 on page 1 and question 3 on page 2
213 * @param integer $page The number of the current page.
214 */
ee1fb969 215function quiz_questions_on_page($layout, $page) {
216 $pages = explode(',0', $layout);
217 return trim($pages[$page], ',');
76cacec8 218}
219
ee1fb969 220/**
26d12a0c 221 * Returns a comma separated list of question ids for the quiz
222 *
223 * @return string Comma separated list of question ids
224 * @param string $layout The string representing the quiz layout. Each page is represented as a
225 * comma separated list of question ids and 0 indicating page breaks.
226 * So 5,2,0,3,0 means questions 5 and 2 on page 1 and question 3 on page 2
227 */
ee1fb969 228function quiz_questions_in_quiz($layout) {
229 return str_replace(',0', '', $layout);
230}
76cacec8 231
ee1fb969 232/**
26d12a0c 233 * Returns the number of pages in the quiz layout
234 *
235 * @return integer Comma separated list of question ids
236 * @param string $layout The string representing the quiz layout.
237 */
ee1fb969 238function quiz_number_of_pages($layout) {
239 return substr_count($layout, ',0');
240}
76cacec8 241
ee1fb969 242/**
26d12a0c 243 * Returns the first question number for the current quiz page
244 *
245 * @return integer The number of the first question
246 * @param string $quizlayout The string representing the layout for the whole quiz
247 * @param string $pagelayout The string representing the layout for the current page
248 */
ee1fb969 249function quiz_first_questionnumber($quizlayout, $pagelayout) {
250 // this works by finding all the questions from the quizlayout that
251 // come before the current page and then adding up their lengths.
9cf4a18b 252 global $CFG, $DB;
49347eac 253 $start = strpos($quizlayout, ','.$pagelayout.',')-2;
ee1fb969 254 if ($start > 0) {
255 $prevlist = substr($quizlayout, 0, $start);
9cf4a18b 256 list($usql, $params) = $DB->get_in_or_equal(explode(',', $prevlist));
257 return $DB->get_field_sql("SELECT sum(length)+1 FROM {question}
258 WHERE id $usql", $params);
76cacec8 259 } else {
ee1fb969 260 return 1;
76cacec8 261 }
ee1fb969 262}
76cacec8 263
ee1fb969 264/**
26d12a0c 265 * Re-paginates the quiz layout
266 *
267 * @return string The new layout string
268 * @param string $layout The string representing the quiz layout.
269 * @param integer $perpage The number of questions per page
270 * @param boolean $shuffle Should the questions be reordered randomly?
271 */
ee1fb969 272function quiz_repaginate($layout, $perpage, $shuffle=false) {
273 $layout = str_replace(',0', '', $layout); // remove existing page breaks
274 $questions = explode(',', $layout);
275 if ($shuffle) {
276 srand((float)microtime() * 1000000); // for php < 4.2
277 shuffle($questions);
278 }
279 $i = 1;
280 $layout = '';
281 foreach ($questions as $question) {
282 if ($perpage and $i > $perpage) {
283 $layout .= '0,';
284 $i = 1;
76cacec8 285 }
ee1fb969 286 $layout .= $question.',';
287 $i++;
76cacec8 288 }
ee1fb969 289 return $layout.'0';
290}
76cacec8 291
ee1fb969 292/**
26d12a0c 293 * Print navigation panel for quiz attempt and review pages
294 *
295 * @param integer $page The number of the current page (counting from 0).
296 * @param integer $pages The total number of pages.
297 */
ee1fb969 298function quiz_print_navigation_panel($page, $pages) {
299 //$page++;
692b631d 300 echo '<div class="paging pagingbar">';
ee1fb969 301 echo '<span class="title">' . get_string('page') . ':</span>';
302 if ($page > 0) {
303 // Print previous link
2f945146 304 $strprev = get_string('previous');
ee1fb969 305 echo '<a href="javascript:navigate(' . ($page - 1) . ');" title="'
306 . $strprev . '">(' . $strprev . ')</a>';
2f945146 307 }
ee1fb969 308 for ($i = 0; $i < $pages; $i++) {
309 if ($i == $page) {
310 echo '<span class="thispage">'.($i+1).'</span>';
2f945146 311 } else {
ee1fb969 312 echo '<a href="javascript:navigate(' . ($i) . ');">'.($i+1).'</a>';
76cacec8 313 }
76cacec8 314 }
ee1fb969 315
316 if ($page < $pages - 1) {
317 // Print next link
2f945146 318 $strnext = get_string('next');
ee1fb969 319 echo '<a href="javascript:navigate(' . ($page + 1) . ');" title="'
320 . $strnext . '">(' . $strnext . ')</a>';
76cacec8 321 }
2f945146 322 echo '</div>';
76cacec8 323}
324
26d12a0c 325/// Functions to do with quiz grades //////////////////////////////////////////
76cacec8 326
ee1fb969 327/**
26d12a0c 328 * Creates an array of maximum grades for a quiz
329 *
330 * The grades are extracted from the quiz_question_instances table.
331 * @return array Array of grades indexed by question id
332 * These are the maximum possible grades that
333 * students can achieve for each of the questions
334 * @param integer $quiz The quiz object
335 */
ee1fb969 336function quiz_get_all_question_grades($quiz) {
9cf4a18b 337 global $CFG, $DB;
76cacec8 338
ee1fb969 339 $questionlist = quiz_questions_in_quiz($quiz->questions);
76cacec8 340 if (empty($questionlist)) {
341 return array();
342 }
343
9cf4a18b 344 $params = array($quiz->id);
345 $wheresql = '';
346 if (!is_null($questionlist)) {
347 list($usql, $question_params) = $DB->get_in_or_equal(explode(',', $questionlist));
348 $wheresql = " AND question $usql ";
349 $params = array_merge($params, $question_params);
350 }
351
352 $instances = $DB->get_records_sql("SELECT question,grade,id
353 FROM {quiz_question_instances}
354 WHERE quiz = ? $wheresql", $params);
76cacec8 355
356 $list = explode(",", $questionlist);
357 $grades = array();
358
359 foreach ($list as $qid) {
ee1fb969 360 if (isset($instances[$qid])) {
361 $grades[$qid] = $instances[$qid]->grade;
76cacec8 362 } else {
363 $grades[$qid] = 1;
364 }
365 }
366 return $grades;
367}
368
212b7b8c 369/**
370 * Get the best current grade for a particular user in a quiz.
77ed3ba5 371 *
212b7b8c 372 * @param object $quiz the quiz object.
373 * @param integer $userid the id of the user.
f88fb62c 374 * @return float the user's current grade for this quiz, or NULL if this user does
375 * not have a grade on this quiz.
212b7b8c 376 */
e37da3a5 377function quiz_get_best_grade($quiz, $userid) {
9cf4a18b 378 global $DB;
379 $grade = $DB->get_field('quiz_grades', 'grade', array('quiz' => $quiz->id, 'userid' => $userid));
212b7b8c 380
381 // Need to detect errors/no result, without catching 0 scores.
382 if (is_numeric($grade)) {
f88fb62c 383 return quiz_format_grade($quiz, $grade);
212b7b8c 384 } else {
cb8057de 385 return NULL;
76cacec8 386 }
212b7b8c 387}
76cacec8 388
212b7b8c 389/**
390 * Convert the raw grade stored in $attempt into a grade out of the maximum
391 * grade for this quiz.
77ed3ba5 392 *
212b7b8c 393 * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades
394 * @param object $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used.
395 * @return float the rescaled grade.
396 */
b7b4f8bd 397function quiz_rescale_grade($rawgrade, $quiz, $round = true) {
212b7b8c 398 if ($quiz->sumgrades) {
b7b4f8bd 399 $grade = $rawgrade * $quiz->grade / $quiz->sumgrades;
400 if ($round) {
f88fb62c 401 $grade = quiz_format_grade($quiz, $grade);
b7b4f8bd 402 }
212b7b8c 403 } else {
b7b4f8bd 404 $grade = 0;
212b7b8c 405 }
b7b4f8bd 406 return $grade;
76cacec8 407}
408
ee1fb969 409/**
212b7b8c 410 * Get the feedback text that should be show to a student who
de266537 411 * got this grade on this quiz. The feedback is processed ready for diplay.
77ed3ba5 412 *
212b7b8c 413 * @param float $grade a grade on this quiz.
414 * @param integer $quizid the id of the quiz object.
415 * @return string the comment that corresponds to this grade (empty string if there is not one.
416 */
417function quiz_feedback_for_grade($grade, $quizid) {
9cf4a18b 418 global $DB;
419 $feedback = $DB->get_field_select('quiz_feedback', 'feedbacktext',
420 "quizid = ? AND mingrade <= ? AND $grade < maxgrade", array($quizid, $grade));
212b7b8c 421
422 if (empty($feedback)) {
423 $feedback = '';
424 }
77ed3ba5 425
de266537 426 // Clean the text, ready for display.
427 $formatoptions = new stdClass;
428 $formatoptions->noclean = true;
429 $feedback = format_text($feedback, FORMAT_MOODLE, $formatoptions);
430
212b7b8c 431 return $feedback;
432}
433
434/**
435 * @param integer $quizid the id of the quiz object.
436 * @return boolean Whether this quiz has any non-blank feedback text.
437 */
438function quiz_has_feedback($quizid) {
9cf4a18b 439 global $DB;
212b7b8c 440 static $cache = array();
441 if (!array_key_exists($quizid, $cache)) {
9cf4a18b 442 $cache[$quizid] = $DB->record_exists_select('quiz_feedback',
245ac557 443 "quizid = ? AND " . $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true), array($quizid));
212b7b8c 444 }
445 return $cache[$quizid];
446}
447
448/**
77ed3ba5 449 * The quiz grade is the score that student's results are marked out of. When it
212b7b8c 450 * changes, the corresponding data in quiz_grades and quiz_feedback needs to be
451 * rescaled.
77ed3ba5 452 *
212b7b8c 453 * @param float $newgrade the new maximum grade for the quiz.
454 * @param object $quiz the quiz we are updating. Passed by reference so its grade field can be updated too.
455 * @return boolean indicating success or failure.
456 */
457function quiz_set_grade($newgrade, &$quiz) {
9cf4a18b 458 global $DB;
212b7b8c 459 // This is potentially expensive, so only do it if necessary.
460 if (abs($quiz->grade - $newgrade) < 1e-7) {
461 // Nothing to do.
462 return true;
463 }
b22fc901 464
212b7b8c 465 // Use a transaction, so that on those databases that support it, this is safer.
dfee5a9a 466 $DB->begin_sql();
77ed3ba5 467
212b7b8c 468 // Update the quiz table.
9cf4a18b 469 $success = $DB->set_field('quiz', 'grade', $newgrade, array('id' => $quiz->instance));
77ed3ba5 470
212b7b8c 471 // Rescaling the other data is only possible if the old grade was non-zero.
472 if ($quiz->grade > 1e-7) {
473 global $CFG;
77ed3ba5 474
212b7b8c 475 $factor = $newgrade/$quiz->grade;
476 $quiz->grade = $newgrade;
477
478 // Update the quiz_grades table.
479 $timemodified = time();
9cf4a18b 480 $success = $success && $DB->execute("
481 UPDATE {quiz_grades}
482 SET grade = ? * grade, timemodified = ?
483 WHERE quiz = ?
484 ", array($factor, $timemodified, $quiz->id));
b22fc901 485
212b7b8c 486 // Update the quiz_grades table.
9cf4a18b 487 $success = $success && $DB->execute("
488 UPDATE {quiz_feedback}
489 SET mingrade = ? * mingrade, maxgrade = ? * maxgrade
490 WHERE quizid = ?
491 ", array($factor, $factor, $quiz->id));
212b7b8c 492 }
77ed3ba5 493
d6dd2108 494 // update grade item and send all grades to gradebook
495 quiz_grade_item_update($quiz);
496 quiz_update_grades($quiz);
497
212b7b8c 498 if ($success) {
9cf4a18b 499 return $DB->commit_sql();
212b7b8c 500 } else {
9cf4a18b 501 $DB->rollback_sql();
212b7b8c 502 return false;
503 }
504}
505
506/**
507 * Save the overall grade for a user at a quiz in the quiz_grades table
508 *
509 * @param object $quiz The quiz for which the best grade is to be calculated and then saved.
510 * @param integer $userid The userid to calculate the grade for. Defaults to the current user.
98f38217 511 * @param array $attempts The attempts of this user. Useful if you are
512 * looping through many users. Attempts can be fetched in one master query to
513 * avoid repeated querying.
212b7b8c 514 * @return boolean Indicates success or failure.
515 */
98f38217 516function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) {
9cf4a18b 517 global $DB;
ee1fb969 518 global $USER;
519
212b7b8c 520 if (empty($userid)) {
ee1fb969 521 $userid = $USER->id;
522 }
76cacec8 523
98f38217 524 if (!$attempts){
525 // Get all the attempts made by the user
526 if (!$attempts = quiz_get_user_attempts($quiz->id, $userid)) {
527 notify('Could not find any user attempts');
528 return false;
529 }
76cacec8 530 }
531
ee1fb969 532 // Calculate the best grade
76cacec8 533 $bestgrade = quiz_calculate_best_grade($quiz, $attempts);
212b7b8c 534 $bestgrade = quiz_rescale_grade($bestgrade, $quiz);
77ed3ba5 535
ee1fb969 536 // Save the best grade in the database
9cf4a18b 537 if ($grade = $DB->get_record('quiz_grades', array('quiz' => $quiz->id, 'userid' => $userid))) {
fc44ee0d 538 $grade->grade = $bestgrade;
76cacec8 539 $grade->timemodified = time();
9cf4a18b 540 if (!$DB->update_record('quiz_grades', $grade)) {
35f45a0d 541 notify('Could not update best grade');
76cacec8 542 return false;
543 }
544 } else {
545 $grade->quiz = $quiz->id;
546 $grade->userid = $userid;
fc44ee0d 547 $grade->grade = $bestgrade;
76cacec8 548 $grade->timemodified = time();
9cf4a18b 549 if (!$DB->insert_record('quiz_grades', $grade)) {
35f45a0d 550 notify('Could not insert new best grade');
76cacec8 551 return false;
552 }
553 }
d6dd2108 554
555 quiz_update_grades($quiz, $userid);
76cacec8 556 return true;
557}
558
fc44ee0d 559/**
26d12a0c 560 * Calculate the overall grade for a quiz given a number of attempts by a particular user.
561 *
562 * @return float The overall grade
563 * @param object $quiz The quiz for which the best grade is to be calculated
564 * @param array $attempts An array of all the attempts of the user at the quiz
565 */
76cacec8 566function quiz_calculate_best_grade($quiz, $attempts) {
76cacec8 567
568 switch ($quiz->grademethod) {
569
ee1fb969 570 case QUIZ_ATTEMPTFIRST:
76cacec8 571 foreach ($attempts as $attempt) {
572 return $attempt->sumgrades;
573 }
574 break;
575
ee1fb969 576 case QUIZ_ATTEMPTLAST:
76cacec8 577 foreach ($attempts as $attempt) {
578 $final = $attempt->sumgrades;
579 }
580 return $final;
581
ee1fb969 582 case QUIZ_GRADEAVERAGE:
76cacec8 583 $sum = 0;
584 $count = 0;
585 foreach ($attempts as $attempt) {
586 $sum += $attempt->sumgrades;
587 $count++;
588 }
589 return (float)$sum/$count;
590
591 default:
ee1fb969 592 case QUIZ_GRADEHIGHEST:
76cacec8 593 $max = 0;
594 foreach ($attempts as $attempt) {
595 if ($attempt->sumgrades > $max) {
596 $max = $attempt->sumgrades;
597 }
598 }
599 return $max;
600 }
601}
602
fc44ee0d 603/**
26d12a0c 604 * Return the attempt with the best grade for a quiz
605 *
606 * Which attempt is the best depends on $quiz->grademethod. If the grade
607 * method is GRADEAVERAGE then this function simply returns the last attempt.
608 * @return object The attempt with the best grade
609 * @param object $quiz The quiz for which the best grade is to be calculated
610 * @param array $attempts An array of all the attempts of the user at the quiz
611 */
76cacec8 612function quiz_calculate_best_attempt($quiz, $attempts) {
76cacec8 613
614 switch ($quiz->grademethod) {
615
ee1fb969 616 case QUIZ_ATTEMPTFIRST:
76cacec8 617 foreach ($attempts as $attempt) {
618 return $attempt;
619 }
620 break;
621
ee1fb969 622 case QUIZ_GRADEAVERAGE: // need to do something with it :-)
623 case QUIZ_ATTEMPTLAST:
76cacec8 624 foreach ($attempts as $attempt) {
625 $final = $attempt;
626 }
627 return $final;
628
629 default:
ee1fb969 630 case QUIZ_GRADEHIGHEST:
76cacec8 631 $max = -1;
632 foreach ($attempts as $attempt) {
633 if ($attempt->sumgrades > $max) {
634 $max = $attempt->sumgrades;
635 $maxattempt = $attempt;
636 }
637 }
638 return $maxattempt;
639 }
640}
641
0675e5eb 642/**
643 * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST.
644 * @return the lang string for that option.
645 */
646function quiz_get_grading_option_name($option) {
647 $strings = quiz_get_grading_options();
648 return $strings[$option];
649}
650
26d12a0c 651/// Other quiz functions ////////////////////////////////////////////////////
ee1fb969 652
ee1fb969 653/**
26d12a0c 654 * Parse field names used for the replace options on question edit forms
655 */
c6bfdec3 656function quiz_parse_fieldname($name, $nameprefix='question') {
657 $reg = array();
9274189f 658 if (preg_match("/$nameprefix(\\d+)(\w+)/", $name, $reg)) {
c6bfdec3 659 return array('mode' => $reg[2], 'id' => (int)$reg[1]);
660 } else {
661 return false;
662 }
663}
0ae98223 664
ee1fb969 665/**
26d12a0c 666 * Upgrade states for an attempt to Moodle 1.5 model
667 *
668 * Any state that does not yet have its timestamp set to nonzero has not yet been upgraded from Moodle 1.4
669 * The reason these are still around is that for large sites it would have taken too long to
670 * upgrade all states at once. This function sets the timestamp field and creates an entry in the
671 * question_sessions table.
672 * @param object $attempt The attempt whose states need upgrading
673 */
ee1fb969 674function quiz_upgrade_states($attempt) {
9cf4a18b 675 global $DB;
ee1fb969 676 global $CFG;
1f48479e 677 // The old quiz model only allowed a single response per quiz attempt so that there will be
678 // only one state record per question for this attempt.
679
680 // We set the timestamp of all states to the timemodified field of the attempt.
9cf4a18b 681 $DB->execute("UPDATE {question_states} SET timestamp = ? WHERE attempt = ?", array($attempt->timemodified, $attempt->uniqueid));
1f48479e 682
03d1753c 683 // For each state we create an entry in the question_sessions table, with both newest and
1f48479e 684 // newgraded pointing to this state.
685 // Actually we only do this for states whose question is actually listed in $attempt->layout.
686 // We do not do it for states associated to wrapped questions like for example the questions
687 // used by a RANDOM question
7c4b621a 688 $session = new stdClass;
689 $session->attemptid = $attempt->uniqueid;
1f48479e 690 $questionlist = quiz_questions_in_quiz($attempt->layout);
9cf4a18b 691 $params = array($attempt->uniqueid);
692 list($usql, $question_params) = $DB->get_in_or_equal(explode(',',$questionlist));
693 $params = array_merge($params, $question_params);
694
695 if ($questionlist and $states = $DB->get_records_select('question_states', "attempt = ? AND question $usql", $params)) {
ee1fb969 696 foreach ($states as $state) {
03d1753c 697 $session->newgraded = $state->id;
698 $session->newest = $state->id;
699 $session->questionid = $state->question;
9cf4a18b 700 $DB->insert_record('question_sessions', $session, false);
ee1fb969 701 }
702 }
703}
26a29b1a 704/**
705 * Function that can be used in various parts of the quiz code.
706 * @param object $quiz
707 * @param integer $cmid
708 * @param object $question
709 * @param string $returnurl url to return to after action is done.
710 * @return string html for a number of icons linked to action pages for a
711 * question - preview and edit / view icons depending on user capabilities.
712 */
713function quiz_question_action_icons($quiz, $cmid, $question, $returnurl){
714 global $CFG;
715 static $stredit = null;
716 static $strview = null;
717 if ($stredit === null){
718 $stredit = get_string('edit');
719 $strview = get_string('view');
720 }
721 $html ='';
722 if (($question->qtype != 'random')){
723 $html .= quiz_question_preview_button($quiz, $question);
724 }
725 $questionparams = array('returnurl' => $returnurl, 'cmid'=>$cmid, 'id' => $question->id);
726 $questionurl = new moodle_url("$CFG->wwwroot/question/question.php", $questionparams);
727 if (question_has_capability_on($question, 'edit', $question->category) || question_has_capability_on($question, 'move', $question->category)) {
728 $html .= "<a title=\"$stredit\" href=\"".$questionurl->out()."\">
729 <img src=\"$CFG->pixpath/t/edit.gif\" class=\"iconsmall\" alt=\"$stredit\" /></a>";
730 } elseif (question_has_capability_on($question, 'view', $question->category)){
731 $html .= "<a title=\"$strview\" href=\"".$questionurl->out(false, array('id'=>$question->id))."\"><img
732 src=\"$CFG->pixpath/i/info.gif\" alt=\"$strview\" /></a>&nbsp;";
733 }
734 return $html;
735}
736
ee1fb969 737
7d87171b 738/**
739 * @param object $quiz the quiz
740 * @param object $question the question
741 * @return the HTML for a preview question icon.
742 */
743function quiz_question_preview_button($quiz, $question) {
e7e62d45 744 global $CFG, $COURSE;
271e6dec 745 if (!question_has_capability_on($question, 'use', $question->category)){
746 return '';
747 }
ad7e7ba8 748 $strpreview = get_string('previewquestion', 'quiz');
e7e62d45 749 $quizorcourseid = $quiz->id?('&amp;quizid=' . $quiz->id):('&amp;courseid=' .$COURSE->id);
750 return link_to_popup_window('/question/preview.php?id=' . $question->id . $quizorcourseid, 'questionpreview',
7d87171b 751 "<img src=\"$CFG->pixpath/t/preview.gif\" class=\"iconsmall\" alt=\"$strpreview\" />",
752 0, 0, $strpreview, QUESTION_PREVIEW_POPUP_OPTIONS, true);
ad7e7ba8 753}
0d156caa 754
62e76c67 755/**
756 * @param object $attempt the attempt.
757 * @param object $context the quiz context.
758 * @return integer whether flags should be shown/editable to the current user for this attempt.
759 */
760function quiz_get_flag_option($attempt, $context) {
761 global $USER;
762 static $flagmode = null;
763 if (is_null($flagmode)) {
764 if (!has_capability('moodle/question:flag', $context)) {
765 $flagmode = QUESTION_FLAGSHIDDEN;
766 } else if ($attempt->userid == $USER->id) {
767 $flagmode = QUESTION_FLAGSEDITABLE;
768 } else {
769 $flagmode = QUESTION_FLAGSSHOWN;
770 }
771 }
772 return $flagmode;
773}
774
4f48fb42 775/**
4dca7e51 776 * Determine render options
777 *
778 * @param int $reviewoptions
779 * @param object $state
780 */
62e76c67 781function quiz_get_renderoptions($quiz, $attempt, $context, $state) {
782 $reviewoptions = $quiz->review;
1b8a7434 783 $options = new stdClass;
77ed3ba5 784
62e76c67 785 $options->flags = quiz_get_flag_option($attempt, $context);
786
4f48fb42 787 // Show the question in readonly (review) mode if the question is in
788 // the closed state
4dca7e51 789 $options->readonly = question_state_is_closed($state);
4f48fb42 790
791 // Show feedback once the question has been graded (if allowed by the quiz)
4dca7e51 792 $options->feedback = question_state_is_graded($state) && ($reviewoptions & QUIZ_REVIEW_FEEDBACK & QUIZ_REVIEW_IMMEDIATELY);
4f48fb42 793
4f48fb42 794 // Show correct responses in readonly mode if the quiz allows it
4dca7e51 795 $options->correct_responses = $options->readonly && ($reviewoptions & QUIZ_REVIEW_ANSWERS & QUIZ_REVIEW_IMMEDIATELY);
4f48fb42 796
a4514d91 797 // Show general feedback if the question has been graded and the quiz allows it.
798 $options->generalfeedback = question_state_is_graded($state) && ($reviewoptions & QUIZ_REVIEW_GENERALFEEDBACK & QUIZ_REVIEW_IMMEDIATELY);
1b8a7434 799
77ed3ba5 800 // Show overallfeedback once the attempt is over.
801 $options->overallfeedback = false;
802
4f48fb42 803 // Always show responses and scores
804 $options->responses = true;
805 $options->scores = true;
a4faed69 806 $options->quizstate = QUIZ_STATE_DURING;
1aab3561 807 $options->history = false;
4f48fb42 808
809 return $options;
810}
811
4f48fb42 812/**
cd06115f 813 * Determine review options
77ed3ba5 814 *
cd06115f 815 * @param object $quiz the quiz instance.
816 * @param object $attempt the attempt in question.
62e76c67 817 * @param $context the quiz module context.
77ed3ba5 818 *
cd06115f 819 * @return object an object with boolean fields responses, scores, feedback,
a4514d91 820 * correct_responses, solutions and general feedback
cd06115f 821 */
62e76c67 822function quiz_get_reviewoptions($quiz, $attempt, $context) {
823 global $USER;
824
1b8a7434 825 $options = new stdClass;
4f48fb42 826 $options->readonly = true;
e2833e87 827
62e76c67 828 $options->flags = quiz_get_flag_option($attempt, $context);
829
b6e907a2 830 // Provide the links to the question review and comment script
b55797b8 831 if (!empty($attempt->id)) {
832 $options->questionreviewlink = '/mod/quiz/reviewquestion.php?attempt=' . $attempt->id;
833 }
b6e907a2 834
e2833e87 835 // Show a link to the comment box only for closed attempts
62e76c67 836 if ($attempt->timefinish && has_capability('mod/quiz:grade', $context)) {
e2833e87 837 $options->questioncommentlink = '/mod/quiz/comment.php';
01600b51 838 }
839
1aab3561 840 // Whether to display a response history.
62e76c67 841 $canviewreports = has_capability('mod/quiz:viewreports', $context);
1aab3561 842 $options->history = ($canviewreports && !$attempt->preview) ? 'all' : 'graded';
843
844 if ($canviewreports && has_capability('moodle/grade:viewhidden', $context) && !$attempt->preview) {
e2833e87 845 // People who can see reports and hidden grades should be shown everything,
846 // except during preview when teachers want to see what students see.
4f48fb42 847 $options->responses = true;
c52c62d1 848 $options->scores = true;
4f48fb42 849 $options->feedback = true;
850 $options->correct_responses = true;
851 $options->solutions = false;
a4514d91 852 $options->generalfeedback = true;
77ed3ba5 853 $options->overallfeedback = true;
a4faed69 854 $options->quizstate = QUIZ_STATE_TEACHERACCESS;
4f48fb42 855 } else {
e2833e87 856 // Work out the state of the attempt ...
857 if (((time() - $attempt->timefinish) < 120) || $attempt->timefinish==0) {
858 $quiz_state_mask = QUIZ_REVIEW_IMMEDIATELY;
859 $options->quizstate = QUIZ_STATE_IMMEDIATELY;
860 } else if (!$quiz->timeclose or time() < $quiz->timeclose) {
861 $quiz_state_mask = QUIZ_REVIEW_OPEN;
862 $options->quizstate = QUIZ_STATE_OPEN;
863 } else {
864 $quiz_state_mask = QUIZ_REVIEW_CLOSED;
865 $options->quizstate = QUIZ_STATE_CLOSED;
866 }
867
c52c62d1 868 // ... and hence extract the appropriate review options.
1b8a7434 869 $options->responses = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_RESPONSES) ? 1 : 0;
870 $options->scores = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_SCORES) ? 1 : 0;
871 $options->feedback = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_FEEDBACK) ? 1 : 0;
872 $options->correct_responses = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_ANSWERS) ? 1 : 0;
873 $options->solutions = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_SOLUTIONS) ? 1 : 0;
a4514d91 874 $options->generalfeedback = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_GENERALFEEDBACK) ? 1 : 0;
00719c02 875 $options->overallfeedback = $attempt->timefinish && ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_OVERALLFEEDBACK);
4f48fb42 876 }
77ed3ba5 877
4f48fb42 878 return $options;
879}
77ed3ba5 880
40377b18 881/**
77ed3ba5 882 * Combines the review options from a number of different quiz attempts.
883 * Returns an array of two ojects, so he suggested way of calling this
884 * funciton is:
885 * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...)
886 *
887 * @param object $quiz the quiz instance.
888 * @param array $attempts an array of attempt objects.
889 * @param $context the roles and permissions context,
890 * normally the context for the quiz module instance.
891 *
892 * @return array of two options objects, one showing which options are true for
893 * at least one of the attempts, the other showing which options are true
894 * for all attempts.
895 */
62e76c67 896function quiz_get_combined_reviewoptions($quiz, $attempts, $context) {
77ed3ba5 897 $fields = array('readonly', 'scores', 'feedback', 'correct_responses', 'solutions', 'generalfeedback', 'overallfeedback');
898 $someoptions = new stdClass;
899 $alloptions = new stdClass;
900 foreach ($fields as $field) {
901 $someoptions->$field = false;
902 $alloptions->$field = true;
40377b18 903 }
77ed3ba5 904 foreach ($attempts as $attempt) {
905 $attemptoptions = quiz_get_reviewoptions($quiz, $attempt, $context);
7d014bf5 906 foreach ($fields as $field) {
907 $someoptions->$field = $someoptions->$field || $attemptoptions->$field;
908 $alloptions->$field = $alloptions->$field && $attemptoptions->$field;
909 }
77ed3ba5 910 }
911 return array($someoptions, $alloptions);
40377b18 912}
ac48e43a 913
914/// FUNCTIONS FOR SENDING NOTIFICATION EMAILS ///////////////////////////////
915
916/**
917 * Sends confirmation email to the student taking the course
918 *
919 * @param stdClass $a associative array of replaceable fields for the templates
920 *
2044a2b2 921 * @return bool|string result of events_triger
ac48e43a 922 */
923function quiz_send_confirmation($a) {
924
925 global $USER;
926
927 // recipient is self
928 $a->useridnumber = $USER->idnumber;
929 $a->username = fullname($USER);
930 $a->userusername = $USER->username;
931
932 // fetch the subject and body from strings
933 $subject = get_string('emailconfirmsubject', 'quiz', $a);
934 $body = get_string('emailconfirmbody', 'quiz', $a);
935
936 // send email and analyse result
3b120e46 937 $eventdata = new object();
938 $eventdata->modulename = 'quiz';
939 $eventdata->userfrom = get_admin();
940 $eventdata->userto = $USER;
941 $eventdata->subject = $subject;
942 $eventdata->fullmessage = $body;
943 $eventdata->fullmessageformat = FORMAT_PLAIN;
944 $eventdata->fullmessagehtml = '';
945 $eventdata->smallmessage = '';
946 return (events_trigger('message_send', $eventdata) == 0);
ac48e43a 947}
948
949/**
950 * Sends notification email to the interested parties that assign the role capability
951 *
952 * @param object $recipient user object of the intended recipient
953 * @param stdClass $a associative array of replaceable fields for the templates
954 *
2044a2b2 955 * @return bool|string result of events_triger()
ac48e43a 956 */
957function quiz_send_notification($recipient, $a) {
958
959 global $USER;
960
961 // recipient info for template
962 $a->username = fullname($recipient);
963 $a->userusername = $recipient->username;
964 $a->userusername = $recipient->username;
965
966 // fetch the subject and body from strings
967 $subject = get_string('emailnotifysubject', 'quiz', $a);
968 $body = get_string('emailnotifybody', 'quiz', $a);
969
970 // send email and analyse result
3b120e46 971 $eventdata = new object();
972 $eventdata->modulename = 'quiz';
973 $eventdata->userfrom = $USER;
974 $eventdata->userto = $recipient;
975 $eventdata->subject = $subject;
976 $eventdata->fullmessage = $body;
977 $eventdata->fullmessageformat = FORMAT_PLAIN;
978 $eventdata->fullmessagehtml = '';
979 $eventdata->smallmessage = '';
980 return (events_trigger('message_send', $eventdata) == 0);
ac48e43a 981}
982
983/**
984 * Takes a bunch of information to format into an email and send
985 * to the specified recipient.
986 *
987 * @param object $course the course
988 * @param object $quiz the quiz
989 * @param object $attempt this attempt just finished
990 * @param object $context the quiz context
991 * @param object $cm the coursemodule for this quiz
992 *
993 * @return int number of emails sent
994 */
995function quiz_send_notification_emails($course, $quiz, $attempt, $context, $cm) {
996 global $CFG, $USER;
997 // we will count goods and bads for error logging
998 $emailresult = array('good' => 0, 'block' => 0, 'fail' => 0);
999
1000 // do nothing if required objects not present
1001 if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1002 debugging('quiz_send_notification_emails: Email(s) not sent due to program error.',
1003 DEBUG_DEVELOPER);
1004 return $emailresult['fail'];
1005 }
1006
1007 // check for confirmation required
1008 $sendconfirm = false;
1009 $notifyexcludeusers = '';
1010 if (has_capability('mod/quiz:emailconfirmsubmission', $context, NULL, false)) {
1011 // exclude from notify emails later
1012 $notifyexcludeusers = $USER->id;
1013 // send the email
1014 $sendconfirm = true;
1015 }
1016
1017 // check for notifications required
1018 $notifyfields = 'u.id, u.username, u.firstname, u.lastname, u.email, u.emailstop, u.lang, u.timezone, u.mailformat, u.maildisplay';
5a128471 1019 $groups = groups_get_all_groups($course->id, $USER->id);
1020 if (is_array($groups) && count($groups) > 0) {
1021 $groups = array_keys($groups);
255985f1 1022 } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) {
1023 // If the user is not in a group, and the quiz is set to group mode,
1024 // then set $gropus to a non-existant id so that only users with
1025 // 'moodle/site:accessallgroups' get notified.
1026 $groups = -1;
5a128471 1027 } else {
1028 $groups = '';
1029 }
ac48e43a 1030 $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission',
5a128471 1031 $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true);
ac48e43a 1032
1033 // if something to send, then build $a
1034 if (! empty($userstonotify) or $sendconfirm) {
1035 $a = new stdClass;
1036 // course info
1037 $a->coursename = $course->fullname;
1038 $a->courseshortname = $course->shortname;
1039 // quiz info
1040 $a->quizname = $quiz->name;
ac48e43a 1041 $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?q=' . $quiz->id;
4f67bf8c 1042 $a->quizreportlink = '<a href="' . $a->quizreporturl . '">' . format_string($quiz->name) . ' report</a>';
ac48e43a 1043 $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id;
4f67bf8c 1044 $a->quizreviewlink = '<a href="' . $a->quizreviewurl . '">' . format_string($quiz->name) . ' review</a>';
ac48e43a 1045 $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?q=' . $quiz->id;
4f67bf8c 1046 $a->quizlink = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>';
ac48e43a 1047 // attempt info
b0972c05 1048 $a->submissiontime = userdate($attempt->timefinish);
1049 $a->timetaken = format_time($attempt->timefinish - $attempt->timestart);
ac48e43a 1050 // student who sat the quiz info
1051 $a->studentidnumber = $USER->idnumber;
1052 $a->studentname = fullname($USER);
1053 $a->studentusername = $USER->username;
1054 }
1055
1056 // send confirmation if required
1057 if ($sendconfirm) {
1058 // send the email and update stats
1059 switch (quiz_send_confirmation($a)) {
1060 case true:
1061 $emailresult['good']++;
1062 break;
1063 case false:
1064 $emailresult['fail']++;
1065 break;
1066 case 'emailstop':
1067 $emailresult['block']++;
1068 break;
1069 }
1070 }
1071
1072 // send notifications if required
1073 if (!empty($userstonotify)) {
1074 // loop through recipients and send an email to each and update stats
1075 foreach ($userstonotify as $recipient) {
1076 switch (quiz_send_notification($recipient, $a)) {
1077 case true:
1078 $emailresult['good']++;
1079 break;
1080 case false:
1081 $emailresult['fail']++;
1082 break;
1083 case 'emailstop':
1084 $emailresult['block']++;
1085 break;
1086 }
1087 }
1088 }
1089
1090 // log errors sending emails if any
1091 if (! empty($emailresult['fail'])) {
1092 debugging('quiz_send_notification_emails:: '.$emailresult['fail'].' email(s) failed to be sent.', DEBUG_DEVELOPER);
1093 }
1094 if (! empty($emailresult['block'])) {
1095 debugging('quiz_send_notification_emails:: '.$emailresult['block'].' email(s) were blocked by the user.', DEBUG_DEVELOPER);
1096 }
1097
1098 // return the number of successfully sent emails
1099 return $emailresult['good'];
1100}
c52c62d1 1101
1102/**
1103 * Print a quiz error message. This is a thin wrapper around print_error, for convinience.
1104 *
1105 * @param mixed $quiz either the quiz object, or the interger quiz id.
1106 * @param string $errorcode the name of the string from quiz.php to print.
1107 * @param object $a any extra data required by the error string.
1108 */
1109function quiz_error($quiz, $errorcode, $a = null) {
1110 global $CFG;
1111 if (is_object($quiz)) {
1112 $quiz = $quiz->id;
1113 }
1114 print_error($errorcode, 'quiz', $CFG->wwwroot . '/mod/quiz/view.php?q=' . $quiz, $a);
1115}
76cacec8 1116?>