Commit | Line | Data |
---|---|---|
83192608 | 1 | <?php |
25302dee TH |
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/>. | |
84e628a0 | 16 | |
ee1fb969 | 17 | /** |
26d12a0c | 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. | |
f63a4ff2 | 25 | * |
ba643847 | 26 | * @package mod |
25302dee | 27 | * @subpackage quiz |
ba643847 TH |
28 | * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} |
29 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
26d12a0c | 30 | */ |
76cacec8 | 31 | |
66d07f81 | 32 | |
a17b297d TH |
33 | defined('MOODLE_INTERNAL') || die(); |
34 | ||
26d12a0c | 35 | require_once($CFG->dirroot . '/mod/quiz/lib.php'); |
c52c62d1 | 36 | require_once($CFG->dirroot . '/mod/quiz/accessrules.php'); |
39759ac4 | 37 | require_once($CFG->dirroot . '/mod/quiz/renderer.php'); |
78e7a3dd | 38 | require_once($CFG->dirroot . '/mod/quiz/attemptlib.php'); |
84e628a0 | 39 | require_once($CFG->dirroot . '/question/editlib.php'); |
3b120e46 | 40 | require_once($CFG->libdir . '/eventslib.php'); |
99d19c13 | 41 | require_once($CFG->libdir . '/filelib.php'); |
76cacec8 | 42 | |
76cacec8 | 43 | |
a4faed69 | 44 | /**#@+ |
25302dee TH |
45 | * Options determining how the grades from individual attempts are combined to give |
46 | * the overall grade for a user | |
a4faed69 | 47 | */ |
7c5bd5bf TH |
48 | define('QUIZ_GRADEHIGHEST', '1'); |
49 | define('QUIZ_GRADEAVERAGE', '2'); | |
50 | define('QUIZ_ATTEMPTFIRST', '3'); | |
51 | define('QUIZ_ATTEMPTLAST', '4'); | |
a4faed69 | 52 | /**#@-*/ |
53 | ||
c52c62d1 | 54 | /** |
55 | * We show the countdown timer if there is less than this amount of time left before the | |
56 | * the quiz close date. (1 hour) | |
57 | */ | |
58 | define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600'); | |
59 | ||
26d12a0c | 60 | /// Functions related to attempts ///////////////////////////////////////// |
ee1fb969 | 61 | |
62 | /** | |
26d12a0c | 63 | * Creates an object to represent a new attempt at a quiz |
64 | * | |
65 | * Creates an attempt object to represent an attempt at the quiz by the current | |
66 | * user starting at the current time. The ->id field is not set. The object is | |
67 | * NOT written to the database. | |
c52c62d1 | 68 | * |
69 | * @param object $quiz the quiz to create an attempt for. | |
f7970e3c | 70 | * @param int $attemptnumber the sequence number for the attempt. |
c52c62d1 | 71 | * @param object $lastattempt the previous attempt by this user, if any. Only needed |
72 | * if $attemptnumber > 1 and $quiz->attemptonlast is true. | |
f7970e3c TH |
73 | * @param int $timenow the time the attempt was started at. |
74 | * @param bool $ispreview whether this new attempt is a preview. | |
c52c62d1 | 75 | * |
76 | * @return object the newly created attempt object. | |
26d12a0c | 77 | */ |
c52c62d1 | 78 | function quiz_create_attempt($quiz, $attemptnumber, $lastattempt, $timenow, $ispreview = false) { |
79 | global $USER; | |
ee1fb969 | 80 | |
e89e09d2 | 81 | if ($attemptnumber == 1 || !$quiz->attemptonlast) { |
25a03faa | 82 | // We are not building on last attempt so create a new attempt. |
0ff4bd08 | 83 | $attempt = new stdClass(); |
75e1df6f | 84 | $attempt->quiz = $quiz->id; |
85 | $attempt->userid = $USER->id; | |
86 | $attempt->preview = 0; | |
87 | if ($quiz->shufflequestions) { | |
25a03faa TH |
88 | $attempt->layout = quiz_clean_layout(quiz_repaginate( |
89 | $quiz->questions, $quiz->questionsperpage, true), true); | |
75e1df6f | 90 | } else { |
25a03faa | 91 | $attempt->layout = quiz_clean_layout($quiz->questions, true); |
75e1df6f | 92 | } |
c52c62d1 | 93 | } else { |
25a03faa | 94 | // Build on last attempt. |
c52c62d1 | 95 | if (empty($lastattempt)) { |
5a2a5331 | 96 | print_error('cannotfindprevattempt', 'quiz'); |
c52c62d1 | 97 | } |
98 | $attempt = $lastattempt; | |
75e1df6f | 99 | } |
100 | ||
ee1fb969 | 101 | $attempt->attempt = $attemptnumber; |
ee1fb969 | 102 | $attempt->timestart = $timenow; |
103 | $attempt->timefinish = 0; | |
104 | $attempt->timemodified = $timenow; | |
75e1df6f | 105 | |
25a03faa | 106 | // If this is a preview, mark it as such. |
c52c62d1 | 107 | if ($ispreview) { |
108 | $attempt->preview = 1; | |
109 | } | |
110 | ||
ee1fb969 | 111 | return $attempt; |
112 | } | |
113 | ||
cd06115f | 114 | /** |
25302dee TH |
115 | * Returns an unfinished attempt (if there is one) for the given |
116 | * user on the given quiz. This function does not return preview attempts. | |
77ed3ba5 | 117 | * |
f7970e3c TH |
118 | * @param int $quizid the id of the quiz. |
119 | * @param int $userid the id of the user. | |
77ed3ba5 | 120 | * |
121 | * @return mixed the unfinished attempt if there is one, false if not. | |
cd06115f | 122 | */ |
0d156caa | 123 | function quiz_get_user_attempt_unfinished($quizid, $userid) { |
ed8bebce | 124 | $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true); |
77ed3ba5 | 125 | if ($attempts) { |
126 | return array_shift($attempts); | |
127 | } else { | |
128 | return false; | |
129 | } | |
920fb237 | 130 | } |
131 | ||
c52c62d1 | 132 | /** |
133 | * Returns the most recent attempt by a given user on a given quiz. | |
134 | * May be finished, or may not. | |
135 | * | |
f7970e3c TH |
136 | * @param int $quizid the id of the quiz. |
137 | * @param int $userid the id of the user. | |
c52c62d1 | 138 | * |
58ffdbb8 | 139 | * @return mixed the attempt if there is one, false if not. |
c52c62d1 | 140 | */ |
141 | function quiz_get_latest_attempt_by_user($quizid, $userid) { | |
9cf4a18b | 142 | global $CFG, $DB; |
25302dee TH |
143 | $attempt = $DB->get_records_sql(' |
144 | SELECT qa.* | |
145 | FROM {quiz_attempts} qa | |
146 | WHERE qa.quiz = ? AND qa.userid = ? | |
147 | ORDER BY qa.timestart DESC, qa.id DESC', | |
148 | array($quizid, $userid), 0, 1); | |
58ffdbb8 | 149 | if ($attempt) { |
150 | return array_shift($attempt); | |
151 | } else { | |
152 | return false; | |
153 | } | |
c52c62d1 | 154 | } |
155 | ||
ff51d646 | 156 | /** |
157 | * Delete a quiz attempt. | |
25a03faa TH |
158 | * @param mixed $attempt an integer attempt id or an attempt object |
159 | * (row of the quiz_attempts table). | |
97344ae3 | 160 | * @param object $quiz the quiz object. |
ff51d646 | 161 | */ |
162 | function quiz_delete_attempt($attempt, $quiz) { | |
9cf4a18b | 163 | global $DB; |
ff51d646 | 164 | if (is_numeric($attempt)) { |
9cf4a18b | 165 | if (!$attempt = $DB->get_record('quiz_attempts', array('id' => $attempt))) { |
ff51d646 | 166 | return; |
167 | } | |
168 | } | |
271e6dec | 169 | |
ff51d646 | 170 | if ($attempt->quiz != $quiz->id) { |
171 | debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " . | |
172 | "but was passed quiz $quiz->id."); | |
173 | return; | |
174 | } | |
271e6dec | 175 | |
25302dee | 176 | question_engine::delete_questions_usage_by_activity($attempt->uniqueid); |
6b5f24d3 | 177 | $DB->delete_records('quiz_attempts', array('id' => $attempt->id)); |
ff51d646 | 178 | |
179 | // Search quiz_attempts for other instances by this user. | |
180 | // If none, then delete record for this quiz, this user from quiz_grades | |
181 | // else recalculate best grade | |
ff51d646 | 182 | $userid = $attempt->userid; |
9cf4a18b | 183 | if (!$DB->record_exists('quiz_attempts', array('userid' => $userid, 'quiz' => $quiz->id))) { |
25a03faa | 184 | $DB->delete_records('quiz_grades', array('userid' => $userid, 'quiz' => $quiz->id)); |
ff51d646 | 185 | } else { |
186 | quiz_save_best_grade($quiz, $userid); | |
187 | } | |
07a7d859 | 188 | |
189 | quiz_update_grades($quiz, $userid); | |
ff51d646 | 190 | } |
191 | ||
4299df1d | 192 | /** |
193 | * Delete all the preview attempts at a quiz, or possibly all the attempts belonging | |
194 | * to one user. | |
195 | * @param object $quiz the quiz object. | |
f7970e3c | 196 | * @param int $userid (optional) if given, only delete the previews belonging to this user. |
4299df1d | 197 | */ |
198 | function quiz_delete_previews($quiz, $userid = null) { | |
199 | global $DB; | |
200 | $conditions = array('quiz' => $quiz->id, 'preview' => 1); | |
201 | if (!empty($userid)) { | |
202 | $conditions['userid'] = $userid; | |
203 | } | |
204 | $previewattempts = $DB->get_records('quiz_attempts', $conditions); | |
205 | foreach ($previewattempts as $attempt) { | |
206 | quiz_delete_attempt($attempt, $quiz); | |
207 | } | |
208 | } | |
209 | ||
84e628a0 | 210 | /** |
f7970e3c TH |
211 | * @param int $quizid The quiz id. |
212 | * @return bool whether this quiz has any (non-preview) attempts. | |
84e628a0 | 213 | */ |
214 | function quiz_has_attempts($quizid) { | |
215 | global $DB; | |
216 | return $DB->record_exists('quiz_attempts', array('quiz' => $quizid, 'preview' => 0)); | |
217 | } | |
218 | ||
26d12a0c | 219 | /// Functions to do with quiz layout and pages //////////////////////////////// |
0d156caa | 220 | |
ee1fb969 | 221 | /** |
26d12a0c | 222 | * Returns a comma separated list of question ids for the quiz |
223 | * | |
1332ce53 | 224 | * @param string $layout The string representing the quiz layout. Each page is |
225 | * represented as a comma separated list of question ids and 0 indicating | |
226 | * page breaks. So 5,2,0,3,0 means questions 5 and 2 on page 1 and question | |
227 | * 3 on page 2 | |
228 | * @return string comma separated list of question ids, without page breaks. | |
26d12a0c | 229 | */ |
ee1fb969 | 230 | function quiz_questions_in_quiz($layout) { |
167f1562 TH |
231 | $questions = str_replace(',0', '', quiz_clean_layout($layout, true)); |
232 | if ($questions === '0') { | |
233 | return ''; | |
234 | } else { | |
235 | return $questions; | |
236 | } | |
ee1fb969 | 237 | } |
76cacec8 | 238 | |
ee1fb969 | 239 | /** |
1332ce53 | 240 | * Returns the number of pages in a quiz layout |
26d12a0c | 241 | * |
1332ce53 | 242 | * @param string $layout The string representing the quiz layout. Always ends in ,0 |
f7970e3c | 243 | * @return int The number of pages in the quiz. |
26d12a0c | 244 | */ |
ee1fb969 | 245 | function quiz_number_of_pages($layout) { |
167f1562 | 246 | return substr_count(',' . $layout, ',0'); |
fa583f5f | 247 | } |
25302dee | 248 | |
fa583f5f | 249 | /** |
250 | * Returns the number of questions in the quiz layout | |
251 | * | |
1332ce53 | 252 | * @param string $layout the string representing the quiz layout. |
f7970e3c | 253 | * @return int The number of questions in the quiz. |
fa583f5f | 254 | */ |
255 | function quiz_number_of_questions_in_quiz($layout) { | |
78634f1e | 256 | $layout = quiz_questions_in_quiz(quiz_clean_layout($layout)); |
1332ce53 | 257 | $count = substr_count($layout, ','); |
258 | if ($layout !== '') { | |
fa583f5f | 259 | $count++; |
260 | } | |
261 | return $count; | |
ee1fb969 | 262 | } |
76cacec8 | 263 | |
ee1fb969 | 264 | /** |
26d12a0c | 265 | * Re-paginates the quiz layout |
266 | * | |
26d12a0c | 267 | * @param string $layout The string representing the quiz layout. |
f7970e3c TH |
268 | * @param int $perpage The number of questions per page |
269 | * @param bool $shuffle Should the questions be reordered randomly? | |
1332ce53 | 270 | * @return string the new layout string |
26d12a0c | 271 | */ |
4299df1d | 272 | function quiz_repaginate($layout, $perpage, $shuffle = false) { |
ee1fb969 | 273 | $layout = str_replace(',0', '', $layout); // remove existing page breaks |
274 | $questions = explode(',', $layout); | |
275 | if ($shuffle) { | |
ee1fb969 | 276 | shuffle($questions); |
277 | } | |
278 | $i = 1; | |
279 | $layout = ''; | |
280 | foreach ($questions as $question) { | |
281 | if ($perpage and $i > $perpage) { | |
282 | $layout .= '0,'; | |
283 | $i = 1; | |
76cacec8 | 284 | } |
ee1fb969 | 285 | $layout .= $question.','; |
286 | $i++; | |
76cacec8 | 287 | } |
ee1fb969 | 288 | return $layout.'0'; |
289 | } | |
76cacec8 | 290 | |
26d12a0c | 291 | /// Functions to do with quiz grades ////////////////////////////////////////// |
76cacec8 | 292 | |
ee1fb969 | 293 | /** |
26d12a0c | 294 | * Creates an array of maximum grades for a quiz |
83192608 | 295 | * |
25302dee TH |
296 | * The grades are extracted from the quiz_question_instances table. |
297 | * @param object $quiz The quiz settings. | |
298 | * @return array of grades indexed by question id. These are the maximum | |
4e6f553d | 299 | * possible grades that students can achieve for each of the questions. |
26d12a0c | 300 | */ |
ee1fb969 | 301 | function quiz_get_all_question_grades($quiz) { |
9cf4a18b | 302 | global $CFG, $DB; |
76cacec8 | 303 | |
ee1fb969 | 304 | $questionlist = quiz_questions_in_quiz($quiz->questions); |
76cacec8 | 305 | if (empty($questionlist)) { |
306 | return array(); | |
307 | } | |
308 | ||
9cf4a18b | 309 | $params = array($quiz->id); |
310 | $wheresql = ''; | |
311 | if (!is_null($questionlist)) { | |
312 | list($usql, $question_params) = $DB->get_in_or_equal(explode(',', $questionlist)); | |
313 | $wheresql = " AND question $usql "; | |
314 | $params = array_merge($params, $question_params); | |
315 | } | |
316 | ||
25a03faa | 317 | $instances = $DB->get_records_sql("SELECT question, grade, id |
9cf4a18b | 318 | FROM {quiz_question_instances} |
319 | WHERE quiz = ? $wheresql", $params); | |
76cacec8 | 320 | |
321 | $list = explode(",", $questionlist); | |
322 | $grades = array(); | |
323 | ||
324 | foreach ($list as $qid) { | |
ee1fb969 | 325 | if (isset($instances[$qid])) { |
326 | $grades[$qid] = $instances[$qid]->grade; | |
76cacec8 | 327 | } else { |
328 | $grades[$qid] = 1; | |
329 | } | |
330 | } | |
331 | return $grades; | |
332 | } | |
333 | ||
212b7b8c | 334 | /** |
335 | * Convert the raw grade stored in $attempt into a grade out of the maximum | |
336 | * grade for this quiz. | |
77ed3ba5 | 337 | * |
212b7b8c | 338 | * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades |
25302dee | 339 | * @param object $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used. |
f7970e3c | 340 | * @param bool|string $format whether to format the results for display |
25302dee | 341 | * or 'question' to format a question grade (different number of decimal places. |
25a03faa TH |
342 | * @return float|string the rescaled grade, or null/the lang string 'notyetgraded' |
343 | * if the $grade is null. | |
212b7b8c | 344 | */ |
25302dee TH |
345 | function quiz_rescale_grade($rawgrade, $quiz, $format = true) { |
346 | if (is_null($rawgrade)) { | |
347 | $grade = null; | |
348 | } else if ($quiz->sumgrades >= 0.000005) { | |
b7b4f8bd | 349 | $grade = $rawgrade * $quiz->grade / $quiz->sumgrades; |
212b7b8c | 350 | } else { |
b7b4f8bd | 351 | $grade = 0; |
212b7b8c | 352 | } |
25302dee TH |
353 | if ($format === 'question') { |
354 | $grade = quiz_format_question_grade($quiz, $grade); | |
355 | } else if ($format) { | |
356 | $grade = quiz_format_grade($quiz, $grade); | |
357 | } | |
b7b4f8bd | 358 | return $grade; |
76cacec8 | 359 | } |
360 | ||
ee1fb969 | 361 | /** |
212b7b8c | 362 | * Get the feedback text that should be show to a student who |
de266537 | 363 | * got this grade on this quiz. The feedback is processed ready for diplay. |
77ed3ba5 | 364 | * |
212b7b8c | 365 | * @param float $grade a grade on this quiz. |
2709ee45 TH |
366 | * @param object $quiz the quiz settings. |
367 | * @param object $context the quiz context. | |
212b7b8c | 368 | * @return string the comment that corresponds to this grade (empty string if there is not one. |
369 | */ | |
2709ee45 | 370 | function quiz_feedback_for_grade($grade, $quiz, $context) { |
9cf4a18b | 371 | global $DB; |
212b7b8c | 372 | |
25302dee TH |
373 | if (is_null($grade)) { |
374 | return ''; | |
375 | } | |
376 | ||
2709ee45 TH |
377 | $feedback = $DB->get_record_select('quiz_feedback', |
378 | 'quizid = ? AND mingrade <= ? AND ? < maxgrade', array($quiz->id, $grade, $grade)); | |
fe6ce234 DC |
379 | |
380 | if (empty($feedback->feedbacktext)) { | |
2709ee45 | 381 | return ''; |
212b7b8c | 382 | } |
77ed3ba5 | 383 | |
de266537 | 384 | // Clean the text, ready for display. |
0ff4bd08 | 385 | $formatoptions = new stdClass(); |
de266537 | 386 | $formatoptions->noclean = true; |
25a03faa TH |
387 | $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext, 'pluginfile.php', |
388 | $context->id, 'mod_quiz', 'feedback', $feedback->id); | |
fe6ce234 | 389 | $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions); |
de266537 | 390 | |
fe6ce234 | 391 | return $feedbacktext; |
212b7b8c | 392 | } |
393 | ||
394 | /** | |
8ce3e17f | 395 | * @param object $quiz the quiz database row. |
f7970e3c | 396 | * @return bool Whether this quiz has any non-blank feedback text. |
212b7b8c | 397 | */ |
739b0711 | 398 | function quiz_has_feedback($quiz) { |
9cf4a18b | 399 | global $DB; |
212b7b8c | 400 | static $cache = array(); |
739b0711 | 401 | if (!array_key_exists($quiz->id, $cache)) { |
402 | $cache[$quiz->id] = quiz_has_grades($quiz) && | |
403 | $DB->record_exists_select('quiz_feedback', "quizid = ? AND " . | |
404 | $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true), | |
405 | array($quiz->id)); | |
212b7b8c | 406 | } |
739b0711 | 407 | return $cache[$quiz->id]; |
212b7b8c | 408 | } |
409 | ||
3c6185e9 TH |
410 | function quiz_no_questions_message($quiz, $cm, $context) { |
411 | global $OUTPUT; | |
412 | ||
413 | $output = ''; | |
414 | $output .= $OUTPUT->notification(get_string('noquestions', 'quiz')); | |
415 | if (has_capability('mod/quiz:manage', $context)) { | |
416 | $output .= $OUTPUT->single_button(new moodle_url('/mod/quiz/edit.php', | |
417 | array('cmid' => $cm->id)), get_string('editquiz', 'quiz'), 'get'); | |
418 | } | |
419 | ||
420 | return $output; | |
421 | } | |
422 | ||
25302dee TH |
423 | /** |
424 | * Update the sumgrades field of the quiz. This needs to be called whenever | |
425 | * the grading structure of the quiz is changed. For example if a question is | |
426 | * added or removed, or a question weight is changed. | |
427 | * | |
428 | * @param object $quiz a quiz. | |
429 | */ | |
430 | function quiz_update_sumgrades($quiz) { | |
431 | global $DB; | |
432 | $sql = 'UPDATE {quiz} | |
433 | SET sumgrades = COALESCE(( | |
434 | SELECT SUM(grade) | |
435 | FROM {quiz_question_instances} | |
436 | WHERE quiz = {quiz}.id | |
437 | ), 0) | |
438 | WHERE id = ?'; | |
a13d4fbd TH |
439 | $DB->execute($sql, array($quiz->id)); |
440 | $quiz->sumgrades = $DB->get_field('quiz', 'sumgrades', array('id' => $quiz->id)); | |
25302dee TH |
441 | if ($quiz->sumgrades < 0.000005) { |
442 | quiz_set_grade(0, $quiz); | |
443 | } | |
444 | } | |
445 | ||
446 | function quiz_update_all_attempt_sumgrades($quiz) { | |
447 | global $DB; | |
448 | $dm = new question_engine_data_mapper(); | |
449 | $timenow = time(); | |
450 | ||
451 | $sql = "UPDATE {quiz_attempts} | |
452 | SET | |
453 | timemodified = :timenow, | |
454 | sumgrades = ( | |
455 | {$dm->sum_usage_marks_subquery('uniqueid')} | |
456 | ) | |
457 | WHERE quiz = :quizid AND timefinish <> 0"; | |
458 | $DB->execute($sql, array('timenow' => $timenow, 'quizid' => $quiz->id)); | |
459 | } | |
460 | ||
212b7b8c | 461 | /** |
b2607ccc | 462 | * The quiz grade is the maximum that student's results are marked out of. When it |
212b7b8c | 463 | * changes, the corresponding data in quiz_grades and quiz_feedback needs to be |
25302dee TH |
464 | * rescaled. After calling this function, you probably need to call |
465 | * quiz_update_all_attempt_sumgrades, quiz_update_all_final_grades and | |
466 | * quiz_update_grades. | |
77ed3ba5 | 467 | * |
212b7b8c | 468 | * @param float $newgrade the new maximum grade for the quiz. |
25a03faa TH |
469 | * @param object $quiz the quiz we are updating. Passed by reference so its |
470 | * grade field can be updated too. | |
f7970e3c | 471 | * @return bool indicating success or failure. |
212b7b8c | 472 | */ |
25302dee | 473 | function quiz_set_grade($newgrade, $quiz) { |
9cf4a18b | 474 | global $DB; |
212b7b8c | 475 | // This is potentially expensive, so only do it if necessary. |
476 | if (abs($quiz->grade - $newgrade) < 1e-7) { | |
477 | // Nothing to do. | |
478 | return true; | |
479 | } | |
b22fc901 | 480 | |
212b7b8c | 481 | // Use a transaction, so that on those databases that support it, this is safer. |
d5a8d9aa PS |
482 | $transaction = $DB->start_delegated_transaction(); |
483 | ||
484 | try { | |
485 | // Update the quiz table. | |
486 | $DB->set_field('quiz', 'grade', $newgrade, array('id' => $quiz->instance)); | |
487 | ||
488 | // Rescaling the other data is only possible if the old grade was non-zero. | |
489 | if ($quiz->grade > 1e-7) { | |
490 | global $CFG; | |
491 | ||
492 | $factor = $newgrade/$quiz->grade; | |
493 | $quiz->grade = $newgrade; | |
494 | ||
495 | // Update the quiz_grades table. | |
496 | $timemodified = time(); | |
497 | $DB->execute(" | |
498 | UPDATE {quiz_grades} | |
499 | SET grade = ? * grade, timemodified = ? | |
500 | WHERE quiz = ? | |
501 | ", array($factor, $timemodified, $quiz->id)); | |
502 | ||
503 | // Update the quiz_feedback table. | |
504 | $DB->execute(" | |
505 | UPDATE {quiz_feedback} | |
506 | SET mingrade = ? * mingrade, maxgrade = ? * maxgrade | |
507 | WHERE quizid = ? | |
508 | ", array($factor, $factor, $quiz->id)); | |
509 | } | |
77ed3ba5 | 510 | |
d5a8d9aa PS |
511 | // update grade item and send all grades to gradebook |
512 | quiz_grade_item_update($quiz); | |
513 | quiz_update_grades($quiz); | |
d6dd2108 | 514 | |
d5a8d9aa PS |
515 | $transaction->allow_commit(); |
516 | return true; | |
517 | ||
518 | } catch (Exception $e) { | |
d5a8d9aa | 519 | $transaction->rollback($e); |
212b7b8c | 520 | } |
521 | } | |
522 | ||
523 | /** | |
524 | * Save the overall grade for a user at a quiz in the quiz_grades table | |
525 | * | |
526 | * @param object $quiz The quiz for which the best grade is to be calculated and then saved. | |
f7970e3c | 527 | * @param int $userid The userid to calculate the grade for. Defaults to the current user. |
98f38217 | 528 | * @param array $attempts The attempts of this user. Useful if you are |
529 | * looping through many users. Attempts can be fetched in one master query to | |
530 | * avoid repeated querying. | |
f7970e3c | 531 | * @return bool Indicates success or failure. |
212b7b8c | 532 | */ |
98f38217 | 533 | function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) { |
9cf4a18b | 534 | global $DB; |
825116fb | 535 | global $USER, $OUTPUT; |
ee1fb969 | 536 | |
212b7b8c | 537 | if (empty($userid)) { |
ee1fb969 | 538 | $userid = $USER->id; |
539 | } | |
76cacec8 | 540 | |
25a03faa | 541 | if (!$attempts) { |
98f38217 | 542 | // Get all the attempts made by the user |
25302dee | 543 | $attempts = quiz_get_user_attempts($quiz->id, $userid); |
76cacec8 | 544 | } |
545 | ||
ee1fb969 | 546 | // Calculate the best grade |
76cacec8 | 547 | $bestgrade = quiz_calculate_best_grade($quiz, $attempts); |
84e628a0 | 548 | $bestgrade = quiz_rescale_grade($bestgrade, $quiz, false); |
77ed3ba5 | 549 | |
ee1fb969 | 550 | // Save the best grade in the database |
25302dee TH |
551 | if (is_null($bestgrade)) { |
552 | $DB->delete_records('quiz_grades', array('quiz' => $quiz->id, 'userid' => $userid)); | |
553 | ||
25a03faa TH |
554 | } else if ($grade = $DB->get_record('quiz_grades', |
555 | array('quiz' => $quiz->id, 'userid' => $userid))) { | |
fc44ee0d | 556 | $grade->grade = $bestgrade; |
76cacec8 | 557 | $grade->timemodified = time(); |
a8c31db2 | 558 | $DB->update_record('quiz_grades', $grade); |
25302dee | 559 | |
76cacec8 | 560 | } else { |
561 | $grade->quiz = $quiz->id; | |
562 | $grade->userid = $userid; | |
fc44ee0d | 563 | $grade->grade = $bestgrade; |
76cacec8 | 564 | $grade->timemodified = time(); |
a8c31db2 | 565 | $DB->insert_record('quiz_grades', $grade); |
76cacec8 | 566 | } |
d6dd2108 | 567 | |
568 | quiz_update_grades($quiz, $userid); | |
76cacec8 | 569 | } |
570 | ||
fc44ee0d | 571 | /** |
26d12a0c | 572 | * Calculate the overall grade for a quiz given a number of attempts by a particular user. |
573 | * | |
574 | * @return float The overall grade | |
575 | * @param object $quiz The quiz for which the best grade is to be calculated | |
576 | * @param array $attempts An array of all the attempts of the user at the quiz | |
577 | */ | |
76cacec8 | 578 | function quiz_calculate_best_grade($quiz, $attempts) { |
76cacec8 | 579 | |
580 | switch ($quiz->grademethod) { | |
581 | ||
ee1fb969 | 582 | case QUIZ_ATTEMPTFIRST: |
76cacec8 | 583 | foreach ($attempts as $attempt) { |
584 | return $attempt->sumgrades; | |
585 | } | |
25302dee | 586 | return $final; |
76cacec8 | 587 | |
ee1fb969 | 588 | case QUIZ_ATTEMPTLAST: |
76cacec8 | 589 | foreach ($attempts as $attempt) { |
590 | $final = $attempt->sumgrades; | |
591 | } | |
592 | return $final; | |
593 | ||
ee1fb969 | 594 | case QUIZ_GRADEAVERAGE: |
76cacec8 | 595 | $sum = 0; |
596 | $count = 0; | |
597 | foreach ($attempts as $attempt) { | |
25302dee TH |
598 | if (!is_null($attempt->sumgrades)) { |
599 | $sum += $attempt->sumgrades; | |
600 | $count++; | |
601 | } | |
76cacec8 | 602 | } |
25302dee TH |
603 | if ($count == 0) { |
604 | return null; | |
605 | } | |
606 | return $sum / $count; | |
76cacec8 | 607 | |
608 | default: | |
ee1fb969 | 609 | case QUIZ_GRADEHIGHEST: |
25302dee | 610 | $max = null; |
76cacec8 | 611 | foreach ($attempts as $attempt) { |
612 | if ($attempt->sumgrades > $max) { | |
613 | $max = $attempt->sumgrades; | |
614 | } | |
615 | } | |
616 | return $max; | |
617 | } | |
618 | } | |
619 | ||
25302dee TH |
620 | /** |
621 | * Update the final grade at this quiz for all students. | |
622 | * | |
623 | * This function is equivalent to calling quiz_save_best_grade for all | |
624 | * users, but much more efficient. | |
625 | * | |
626 | * @param object $quiz the quiz settings. | |
627 | */ | |
628 | function quiz_update_all_final_grades($quiz) { | |
629 | global $DB; | |
630 | ||
631 | if (!$quiz->sumgrades) { | |
632 | return; | |
633 | } | |
634 | ||
635 | $param = array('iquizid' => $quiz->id); | |
636 | $firstlastattemptjoin = "JOIN ( | |
637 | SELECT | |
638 | iquiza.userid, | |
639 | MIN(attempt) AS firstattempt, | |
640 | MAX(attempt) AS lastattempt | |
641 | ||
642 | FROM {quiz_attempts iquiza} | |
643 | ||
644 | WHERE | |
645 | iquiza.timefinish <> 0 AND | |
646 | iquiza.preview = 0 AND | |
647 | iquiza.quiz = :iquizid | |
648 | ||
649 | GROUP BY iquiza.userid | |
650 | ) first_last_attempts ON first_last_attempts.userid = quiza.userid"; | |
651 | ||
652 | switch ($quiz->grademethod) { | |
653 | case QUIZ_ATTEMPTFIRST: | |
654 | // Becuase of the where clause, there will only be one row, but we | |
655 | // must still use an aggregate function. | |
656 | $select = 'MAX(quiza.sumgrades)'; | |
657 | $join = $firstlastattemptjoin; | |
658 | $where = 'quiza.attempt = first_last_attempts.firstattempt AND'; | |
659 | break; | |
660 | ||
661 | case QUIZ_ATTEMPTLAST: | |
662 | // Becuase of the where clause, there will only be one row, but we | |
663 | // must still use an aggregate function. | |
664 | $select = 'MAX(quiza.sumgrades)'; | |
665 | $join = $firstlastattemptjoin; | |
666 | $where = 'quiza.attempt = first_last_attempts.lastattempt AND'; | |
667 | break; | |
668 | ||
669 | case QUIZ_GRADEAVERAGE: | |
670 | $select = 'AVG(quiza.sumgrades)'; | |
671 | $join = ''; | |
672 | $where = ''; | |
673 | break; | |
674 | ||
675 | default: | |
676 | case QUIZ_GRADEHIGHEST: | |
677 | $select = 'MAX(quiza.sumgrades)'; | |
678 | $join = ''; | |
679 | $where = ''; | |
680 | break; | |
681 | } | |
682 | ||
683 | if ($quiz->sumgrades >= 0.000005) { | |
684 | $finalgrade = $select . ' * ' . ($quiz->grade / $quiz->sumgrades); | |
685 | } else { | |
686 | $finalgrade = '0'; | |
687 | } | |
688 | $param['quizid'] = $quiz->id; | |
689 | $param['quizid2'] = $quiz->id; | |
690 | $param['quizid3'] = $quiz->id; | |
691 | $param['quizid4'] = $quiz->id; | |
692 | $finalgradesubquery = " | |
693 | SELECT quiza.userid, $finalgrade AS newgrade | |
694 | FROM {quiz_attempts} quiza | |
695 | $join | |
696 | WHERE | |
697 | $where | |
698 | quiza.timefinish <> 0 AND | |
699 | quiza.preview = 0 AND | |
700 | quiza.quiz = :quizid3 | |
701 | GROUP BY quiza.userid"; | |
702 | ||
703 | $changedgrades = $DB->get_records_sql(" | |
704 | SELECT users.userid, qg.id, qg.grade, newgrades.newgrade | |
705 | ||
706 | FROM ( | |
707 | SELECT userid | |
708 | FROM {quiz_grades} qg | |
709 | WHERE quiz = :quizid | |
710 | UNION | |
711 | SELECT DISTINCT userid | |
712 | FROM {quiz_attempts} quiza2 | |
713 | WHERE | |
714 | quiza2.timefinish <> 0 AND | |
715 | quiza2.preview = 0 AND | |
716 | quiza2.quiz = :quizid2 | |
717 | ) users | |
718 | ||
719 | LEFT JOIN {quiz_grades} qg ON qg.userid = users.userid AND qg.quiz = :quizid4 | |
720 | ||
721 | LEFT JOIN ( | |
722 | $finalgradesubquery | |
723 | ) newgrades ON newgrades.userid = users.userid | |
724 | ||
725 | WHERE | |
726 | ABS(newgrades.newgrade - qg.grade) > 0.000005 OR | |
727 | (newgrades.newgrade IS NULL) <> (qg.grade IS NULL)", | |
a13d4fbd | 728 | $param); |
25302dee TH |
729 | |
730 | $timenow = time(); | |
731 | $todelete = array(); | |
732 | foreach ($changedgrades as $changedgrade) { | |
733 | ||
734 | if (is_null($changedgrade->newgrade)) { | |
735 | $todelete[] = $changedgrade->userid; | |
736 | ||
737 | } else if (is_null($changedgrade->grade)) { | |
0ff4bd08 | 738 | $toinsert = new stdClass(); |
25302dee TH |
739 | $toinsert->quiz = $quiz->id; |
740 | $toinsert->userid = $changedgrade->userid; | |
741 | $toinsert->timemodified = $timenow; | |
742 | $toinsert->grade = $changedgrade->newgrade; | |
743 | $DB->insert_record('quiz_grades', $toinsert); | |
744 | ||
745 | } else { | |
0ff4bd08 | 746 | $toupdate = new stdClass(); |
25302dee TH |
747 | $toupdate->id = $changedgrade->id; |
748 | $toupdate->grade = $changedgrade->newgrade; | |
749 | $toupdate->timemodified = $timenow; | |
750 | $DB->update_record('quiz_grades', $toupdate); | |
751 | } | |
752 | } | |
753 | ||
754 | if (!empty($todelete)) { | |
e65f89b9 | 755 | list($test, $params) = $DB->get_in_or_equal($todelete); |
8d13ea83 TH |
756 | $DB->delete_records_select('quiz_grades', 'quiz = ? AND userid ' . $test, |
757 | array_merge(array($quiz->id), $params)); | |
25302dee TH |
758 | } |
759 | } | |
760 | ||
fc44ee0d | 761 | /** |
26d12a0c | 762 | * Return the attempt with the best grade for a quiz |
763 | * | |
764 | * Which attempt is the best depends on $quiz->grademethod. If the grade | |
765 | * method is GRADEAVERAGE then this function simply returns the last attempt. | |
766 | * @return object The attempt with the best grade | |
767 | * @param object $quiz The quiz for which the best grade is to be calculated | |
768 | * @param array $attempts An array of all the attempts of the user at the quiz | |
769 | */ | |
76cacec8 | 770 | function quiz_calculate_best_attempt($quiz, $attempts) { |
76cacec8 | 771 | |
772 | switch ($quiz->grademethod) { | |
773 | ||
ee1fb969 | 774 | case QUIZ_ATTEMPTFIRST: |
76cacec8 | 775 | foreach ($attempts as $attempt) { |
776 | return $attempt; | |
777 | } | |
778 | break; | |
779 | ||
ee1fb969 | 780 | case QUIZ_GRADEAVERAGE: // need to do something with it :-) |
781 | case QUIZ_ATTEMPTLAST: | |
76cacec8 | 782 | foreach ($attempts as $attempt) { |
783 | $final = $attempt; | |
784 | } | |
785 | return $final; | |
786 | ||
787 | default: | |
ee1fb969 | 788 | case QUIZ_GRADEHIGHEST: |
76cacec8 | 789 | $max = -1; |
790 | foreach ($attempts as $attempt) { | |
791 | if ($attempt->sumgrades > $max) { | |
792 | $max = $attempt->sumgrades; | |
793 | $maxattempt = $attempt; | |
794 | } | |
795 | } | |
796 | return $maxattempt; | |
797 | } | |
798 | } | |
799 | ||
25302dee TH |
800 | /** |
801 | * @return the options for calculating the quiz grade from the individual attempt grades. | |
802 | */ | |
803 | function quiz_get_grading_options() { | |
804 | return array( | |
805 | QUIZ_GRADEHIGHEST => get_string('gradehighest', 'quiz'), | |
806 | QUIZ_GRADEAVERAGE => get_string('gradeaverage', 'quiz'), | |
807 | QUIZ_ATTEMPTFIRST => get_string('attemptfirst', 'quiz'), | |
808 | QUIZ_ATTEMPTLAST => get_string('attemptlast', 'quiz') | |
809 | ); | |
810 | } | |
811 | ||
0675e5eb | 812 | /** |
25a03faa TH |
813 | * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, |
814 | * QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST. | |
0675e5eb | 815 | * @return the lang string for that option. |
816 | */ | |
817 | function quiz_get_grading_option_name($option) { | |
818 | $strings = quiz_get_grading_options(); | |
819 | return $strings[$option]; | |
820 | } | |
821 | ||
26d12a0c | 822 | /// Other quiz functions //////////////////////////////////////////////////// |
ee1fb969 | 823 | |
26a29b1a | 824 | /** |
2a874d65 | 825 | * @param object $quiz the quiz. |
f7970e3c | 826 | * @param int $cmid the course_module object for this quiz. |
2a874d65 | 827 | * @param object $question the question. |
26a29b1a | 828 | * @param string $returnurl url to return to after action is done. |
829 | * @return string html for a number of icons linked to action pages for a | |
830 | * question - preview and edit / view icons depending on user capabilities. | |
831 | */ | |
2a874d65 | 832 | function quiz_question_action_icons($quiz, $cmid, $question, $returnurl) { |
833 | $html = quiz_question_preview_button($quiz, $question) . ' ' . | |
834 | quiz_question_edit_button($cmid, $question, $returnurl); | |
835 | return $html; | |
836 | } | |
837 | ||
838 | /** | |
f7970e3c | 839 | * @param int $cmid the course_module.id for this quiz. |
2a874d65 | 840 | * @param object $question the question. |
841 | * @param string $returnurl url to return to after action is done. | |
842 | * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon. | |
25a03faa TH |
843 | * @return the HTML for an edit icon, view icon, or nothing for a question |
844 | * (depending on permissions). | |
2a874d65 | 845 | */ |
d04f623b | 846 | function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') { |
d436d197 | 847 | global $CFG, $OUTPUT; |
2a874d65 | 848 | |
849 | // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page. | |
26a29b1a | 850 | static $stredit = null; |
851 | static $strview = null; | |
25a03faa | 852 | if ($stredit === null) { |
26a29b1a | 853 | $stredit = get_string('edit'); |
854 | $strview = get_string('view'); | |
855 | } | |
2a874d65 | 856 | |
857 | // What sort of icon should we show? | |
858 | $action = ''; | |
25a03faa TH |
859 | if (!empty($question->id) && |
860 | (question_has_capability_on($question, 'edit', $question->category) || | |
861 | question_has_capability_on($question, 'move', $question->category))) { | |
2a874d65 | 862 | $action = $stredit; |
863 | $icon = '/t/edit'; | |
25a03faa TH |
864 | } else if (!empty($question->id) && |
865 | question_has_capability_on($question, 'view', $question->category)) { | |
2a874d65 | 866 | $action = $strview; |
867 | $icon = '/i/info'; | |
868 | } | |
869 | ||
870 | // Build the icon. | |
871 | if ($action) { | |
872 | $questionparams = array('returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id); | |
873 | $questionurl = new moodle_url("$CFG->wwwroot/question/question.php", $questionparams); | |
d04f623b TH |
874 | return '<a title="' . $action . '" href="' . $questionurl->out() . '"><img src="' . |
875 | $OUTPUT->pix_url($icon) . '" alt="' . $action . '" />' . $contentaftericon . | |
876 | '</a>'; | |
2a874d65 | 877 | } else { |
d04f623b | 878 | return $contentaftericon; |
26a29b1a | 879 | } |
26a29b1a | 880 | } |
881 | ||
7d87171b | 882 | /** |
612106b3 TH |
883 | * @param object $quiz the quiz settings |
884 | * @param object $question the question | |
885 | * @return moodle_url to preview this question with the options from this quiz. | |
886 | */ | |
887 | function quiz_question_preview_url($quiz, $question) { | |
888 | // Get the appropriate display options. | |
889 | $displayoptions = mod_quiz_display_options::make_from_quiz($quiz, | |
890 | mod_quiz_display_options::DURING); | |
891 | ||
892 | $maxmark = null; | |
893 | if (isset($question->maxmark)) { | |
894 | $maxmark = $question->maxmark; | |
895 | } | |
896 | ||
897 | // Work out the correcte preview URL. | |
898 | return question_preview_url($question->id, $quiz->preferredbehaviour, | |
899 | $maxmark, $displayoptions); | |
900 | } | |
901 | ||
902 | /** | |
903 | * @param object $quiz the quiz settings | |
7d87171b | 904 | * @param object $question the question |
f7970e3c | 905 | * @param bool $label if true, show the preview question label after the icon |
7d87171b | 906 | * @return the HTML for a preview question icon. |
907 | */ | |
2a874d65 | 908 | function quiz_question_preview_button($quiz, $question, $label = false) { |
4096752d | 909 | global $CFG, $COURSE, $OUTPUT; |
2a874d65 | 910 | if (!question_has_capability_on($question, 'use', $question->category)) { |
271e6dec | 911 | return ''; |
912 | } | |
2a874d65 | 913 | |
612106b3 | 914 | $url = quiz_question_preview_url($quiz, $question); |
2a874d65 | 915 | |
916 | // Do we want a label? | |
25302dee | 917 | $strpreviewlabel = ''; |
2a874d65 | 918 | if ($label) { |
25302dee | 919 | $strpreviewlabel = get_string('preview', 'quiz'); |
fa583f5f | 920 | } |
2a874d65 | 921 | |
922 | // Build the icon. | |
f9b0500f | 923 | $strpreviewquestion = get_string('previewquestion', 'quiz'); |
000c278c | 924 | $image = $OUTPUT->pix_icon('t/preview', $strpreviewquestion); |
ca4b4f8f | 925 | |
612106b3 TH |
926 | $action = new popup_action('click', $url, 'questionpreview', |
927 | question_preview_popup_params()); | |
4bcc5118 | 928 | |
25302dee | 929 | return $OUTPUT->action_link($url, $image, $action, array('title' => $strpreviewquestion)); |
ad7e7ba8 | 930 | } |
0d156caa | 931 | |
62e76c67 | 932 | /** |
933 | * @param object $attempt the attempt. | |
934 | * @param object $context the quiz context. | |
f7970e3c | 935 | * @return int whether flags should be shown/editable to the current user for this attempt. |
62e76c67 | 936 | */ |
937 | function quiz_get_flag_option($attempt, $context) { | |
938 | global $USER; | |
25302dee TH |
939 | if (!has_capability('moodle/question:flag', $context)) { |
940 | return question_display_options::HIDDEN; | |
941 | } else if ($attempt->userid == $USER->id) { | |
942 | return question_display_options::EDITABLE; | |
943 | } else { | |
944 | return question_display_options::VISIBLE; | |
62e76c67 | 945 | } |
62e76c67 | 946 | } |
947 | ||
4f48fb42 | 948 | /** |
25302dee TH |
949 | * Work out what state this quiz attempt is in. |
950 | * @param object $quiz the quiz settings | |
951 | * @param object $attempt the quiz_attempt database row. | |
f7970e3c | 952 | * @return int one of the mod_quiz_display_options::DURING, |
25302dee | 953 | * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. |
4dca7e51 | 954 | */ |
25302dee TH |
955 | function quiz_attempt_state($quiz, $attempt) { |
956 | if ($attempt->timefinish == 0) { | |
957 | return mod_quiz_display_options::DURING; | |
958 | } else if (time() < $attempt->timefinish + 120) { | |
959 | return mod_quiz_display_options::IMMEDIATELY_AFTER; | |
960 | } else if (!$quiz->timeclose || time() < $quiz->timeclose) { | |
961 | return mod_quiz_display_options::LATER_WHILE_OPEN; | |
962 | } else { | |
963 | return mod_quiz_display_options::AFTER_CLOSE; | |
964 | } | |
4f48fb42 | 965 | } |
966 | ||
4f48fb42 | 967 | /** |
25302dee TH |
968 | * The the appropraite mod_quiz_display_options object for this attempt at this |
969 | * quiz right now. | |
77ed3ba5 | 970 | * |
cd06115f | 971 | * @param object $quiz the quiz instance. |
972 | * @param object $attempt the attempt in question. | |
25302dee | 973 | * @param $context the quiz context. |
77ed3ba5 | 974 | * |
25302dee | 975 | * @return mod_quiz_display_options |
cd06115f | 976 | */ |
7ee80cab | 977 | function quiz_get_review_options($quiz, $attempt, $context) { |
25302dee | 978 | $options = mod_quiz_display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt)); |
62e76c67 | 979 | |
4f48fb42 | 980 | $options->readonly = true; |
62e76c67 | 981 | $options->flags = quiz_get_flag_option($attempt, $context); |
2709ee45 TH |
982 | if (!empty($attempt->id)) { |
983 | $options->questionreviewlink = new moodle_url('/mod/quiz/reviewquestion.php', | |
984 | array('attempt' => $attempt->id)); | |
985 | } | |
b6e907a2 | 986 | |
e2833e87 | 987 | // Show a link to the comment box only for closed attempts |
2709ee45 TH |
988 | if (!empty($attempt->id) && $attempt->timefinish && !$attempt->preview && |
989 | !is_null($context) && has_capability('mod/quiz:grade', $context)) { | |
25302dee | 990 | $options->manualcomment = question_display_options::VISIBLE; |
7ee80cab TH |
991 | $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php', |
992 | array('attempt' => $attempt->id)); | |
01600b51 | 993 | } |
994 | ||
25a03faa TH |
995 | if (!is_null($context) && !$attempt->preview && |
996 | has_capability('mod/quiz:viewreports', $context) && | |
25302dee | 997 | has_capability('moodle/grade:viewhidden', $context)) { |
e2833e87 | 998 | // People who can see reports and hidden grades should be shown everything, |
999 | // except during preview when teachers want to see what students see. | |
25302dee TH |
1000 | $options->attempt = question_display_options::VISIBLE; |
1001 | $options->correctness = question_display_options::VISIBLE; | |
1002 | $options->marks = question_display_options::MARK_AND_MAX; | |
1003 | $options->feedback = question_display_options::VISIBLE; | |
1004 | $options->numpartscorrect = question_display_options::VISIBLE; | |
1005 | $options->generalfeedback = question_display_options::VISIBLE; | |
1006 | $options->rightanswer = question_display_options::VISIBLE; | |
1007 | $options->overallfeedback = question_display_options::VISIBLE; | |
1008 | $options->history = question_display_options::VISIBLE; | |
e2833e87 | 1009 | |
4f48fb42 | 1010 | } |
77ed3ba5 | 1011 | |
4f48fb42 | 1012 | return $options; |
1013 | } | |
77ed3ba5 | 1014 | |
40377b18 | 1015 | /** |
77ed3ba5 | 1016 | * Combines the review options from a number of different quiz attempts. |
25302dee | 1017 | * Returns an array of two ojects, so the suggested way of calling this |
77ed3ba5 | 1018 | * funciton is: |
1019 | * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...) | |
1020 | * | |
1021 | * @param object $quiz the quiz instance. | |
1022 | * @param array $attempts an array of attempt objects. | |
1023 | * @param $context the roles and permissions context, | |
1024 | * normally the context for the quiz module instance. | |
1025 | * | |
1026 | * @return array of two options objects, one showing which options are true for | |
1027 | * at least one of the attempts, the other showing which options are true | |
1028 | * for all attempts. | |
1029 | */ | |
25302dee | 1030 | function quiz_get_combined_reviewoptions($quiz, $attempts) { |
6d03fd98 | 1031 | $fields = array('feedback', 'generalfeedback', 'rightanswer', 'overallfeedback'); |
0ff4bd08 TH |
1032 | $someoptions = new stdClass(); |
1033 | $alloptions = new stdClass(); | |
77ed3ba5 | 1034 | foreach ($fields as $field) { |
1035 | $someoptions->$field = false; | |
1036 | $alloptions->$field = true; | |
40377b18 | 1037 | } |
6d03fd98 TH |
1038 | $someoptions->marks = question_display_options::HIDDEN; |
1039 | $alloptions->marks = question_display_options::MARK_AND_MAX; | |
1040 | ||
77ed3ba5 | 1041 | foreach ($attempts as $attempt) { |
25302dee TH |
1042 | $attemptoptions = mod_quiz_display_options::make_from_quiz($quiz, |
1043 | quiz_attempt_state($quiz, $attempt)); | |
7d014bf5 | 1044 | foreach ($fields as $field) { |
1045 | $someoptions->$field = $someoptions->$field || $attemptoptions->$field; | |
1046 | $alloptions->$field = $alloptions->$field && $attemptoptions->$field; | |
1047 | } | |
6d03fd98 TH |
1048 | $someoptions->marks = max($someoptions->marks, $attemptoptions->marks); |
1049 | $alloptions->marks = min($alloptions->marks, $attemptoptions->marks); | |
77ed3ba5 | 1050 | } |
1051 | return array($someoptions, $alloptions); | |
40377b18 | 1052 | } |
ac48e43a | 1053 | |
25302dee TH |
1054 | /** |
1055 | * Clean the question layout from various possible anomalies: | |
1056 | * - Remove consecutive ","'s | |
1057 | * - Remove duplicate question id's | |
1058 | * - Remove extra "," from beginning and end | |
1059 | * - Finally, add a ",0" in the end if there is none | |
1060 | * | |
1061 | * @param $string $layout the quiz layout to clean up, usually from $quiz->questions. | |
f7970e3c | 1062 | * @param bool $removeemptypages If true, remove empty pages from the quiz. False by default. |
25302dee TH |
1063 | * @return $string the cleaned-up layout |
1064 | */ | |
1065 | function quiz_clean_layout($layout, $removeemptypages = false) { | |
1066 | // Remove repeated ','s. This can happen when a restore fails to find the right | |
1067 | // id to relink to. | |
1068 | $layout = preg_replace('/,{2,}/', ',', trim($layout, ',')); | |
1069 | ||
1070 | // Remove duplicate question ids | |
1071 | $layout = explode(',', $layout); | |
1072 | $cleanerlayout = array(); | |
1073 | $seen = array(); | |
1074 | foreach ($layout as $item) { | |
1075 | if ($item == 0) { | |
1076 | $cleanerlayout[] = '0'; | |
1077 | } else if (!in_array($item, $seen)) { | |
1078 | $cleanerlayout[] = $item; | |
1079 | $seen[] = $item; | |
1080 | } | |
1081 | } | |
1082 | ||
1083 | if ($removeemptypages) { | |
1084 | // Avoid duplicate page breaks | |
1085 | $layout = $cleanerlayout; | |
1086 | $cleanerlayout = array(); | |
1087 | $stripfollowingbreaks = true; // Ensure breaks are stripped from the start. | |
1088 | foreach ($layout as $item) { | |
1089 | if ($stripfollowingbreaks && $item == 0) { | |
1090 | continue; | |
1091 | } | |
1092 | $cleanerlayout[] = $item; | |
1093 | $stripfollowingbreaks = $item == 0; | |
1094 | } | |
1095 | } | |
1096 | ||
1097 | // Add a page break at the end if there is none | |
1098 | if (end($cleanerlayout) !== '0') { | |
1099 | $cleanerlayout[] = '0'; | |
1100 | } | |
1101 | ||
1102 | return implode(',', $cleanerlayout); | |
1103 | } | |
1104 | ||
1105 | /** | |
1106 | * Get the slot for a question with a particular id. | |
1107 | * @param object $quiz the quiz settings. | |
f7970e3c TH |
1108 | * @param int $questionid the of a question in the quiz. |
1109 | * @return int the corresponding slot. Null if the question is not in the quiz. | |
25302dee TH |
1110 | */ |
1111 | function quiz_get_slot_for_question($quiz, $questionid) { | |
1112 | $questionids = quiz_questions_in_quiz($quiz->questions); | |
1113 | foreach (explode(',', $questionids) as $key => $id) { | |
1114 | if ($id == $questionid) { | |
1115 | return $key + 1; | |
1116 | } | |
1117 | } | |
1118 | return null; | |
1119 | } | |
1120 | ||
ac48e43a | 1121 | /// FUNCTIONS FOR SENDING NOTIFICATION EMAILS /////////////////////////////// |
1122 | ||
1123 | /** | |
1124 | * Sends confirmation email to the student taking the course | |
1125 | * | |
0ff4bd08 | 1126 | * @param object $a associative array of replaceable fields for the templates |
ac48e43a | 1127 | * |
d83bad41 | 1128 | * @return bool |
ac48e43a | 1129 | */ |
1130 | function quiz_send_confirmation($a) { | |
1131 | ||
1132 | global $USER; | |
1133 | ||
1134 | // recipient is self | |
1135 | $a->useridnumber = $USER->idnumber; | |
1136 | $a->username = fullname($USER); | |
1137 | $a->userusername = $USER->username; | |
1138 | ||
1139 | // fetch the subject and body from strings | |
1140 | $subject = get_string('emailconfirmsubject', 'quiz', $a); | |
1141 | $body = get_string('emailconfirmbody', 'quiz', $a); | |
1142 | ||
1143 | // send email and analyse result | |
39790bd8 | 1144 | $eventdata = new stdClass(); |
94263cca AD |
1145 | $eventdata->component = 'mod_quiz'; |
1146 | $eventdata->name = 'confirmation'; | |
1147 | $eventdata->notification = 1; | |
1148 | ||
3b120e46 | 1149 | $eventdata->userfrom = get_admin(); |
1150 | $eventdata->userto = $USER; | |
1151 | $eventdata->subject = $subject; | |
1152 | $eventdata->fullmessage = $body; | |
1153 | $eventdata->fullmessageformat = FORMAT_PLAIN; | |
1154 | $eventdata->fullmessagehtml = ''; | |
94263cca AD |
1155 | |
1156 | $eventdata->smallmessage = get_string('emailconfirmsmall', 'quiz', $a); | |
1157 | $eventdata->contexturl = $a->quizurl; | |
1158 | $eventdata->contexturlname = $a->quizname; | |
99d19c13 | 1159 | |
d83bad41 | 1160 | return (bool)message_send($eventdata); // returns message id or false |
ac48e43a | 1161 | } |
1162 | ||
1163 | /** | |
bc68fc9a | 1164 | * Sends notification messages to the interested parties that assign the role capability |
ac48e43a | 1165 | * |
1166 | * @param object $recipient user object of the intended recipient | |
0ff4bd08 | 1167 | * @param object $a associative array of replaceable fields for the templates |
ac48e43a | 1168 | * |
d83bad41 | 1169 | * @return bool |
ac48e43a | 1170 | */ |
1171 | function quiz_send_notification($recipient, $a) { | |
1172 | ||
1173 | global $USER; | |
1174 | ||
1175 | // recipient info for template | |
1176 | $a->username = fullname($recipient); | |
1177 | $a->userusername = $recipient->username; | |
ac48e43a | 1178 | |
1179 | // fetch the subject and body from strings | |
1180 | $subject = get_string('emailnotifysubject', 'quiz', $a); | |
1181 | $body = get_string('emailnotifybody', 'quiz', $a); | |
1182 | ||
1183 | // send email and analyse result | |
39790bd8 | 1184 | $eventdata = new stdClass(); |
bc68fc9a AD |
1185 | $eventdata->component = 'mod_quiz'; |
1186 | $eventdata->name = 'submission'; | |
1187 | $eventdata->notification = 1; | |
1188 | ||
3b120e46 | 1189 | $eventdata->userfrom = $USER; |
1190 | $eventdata->userto = $recipient; | |
1191 | $eventdata->subject = $subject; | |
1192 | $eventdata->fullmessage = $body; | |
1193 | $eventdata->fullmessageformat = FORMAT_PLAIN; | |
1194 | $eventdata->fullmessagehtml = ''; | |
bc68fc9a AD |
1195 | |
1196 | $eventdata->smallmessage = get_string('emailnotifysmall', 'quiz', $a); | |
1197 | $eventdata->contexturl = $a->quizreviewurl; | |
1198 | $eventdata->contexturlname = $a->quizname; | |
1199 | ||
d83bad41 | 1200 | return (bool)message_send($eventdata); |
ac48e43a | 1201 | } |
1202 | ||
1203 | /** | |
1204 | * Takes a bunch of information to format into an email and send | |
1205 | * to the specified recipient. | |
1206 | * | |
1207 | * @param object $course the course | |
1208 | * @param object $quiz the quiz | |
1209 | * @param object $attempt this attempt just finished | |
1210 | * @param object $context the quiz context | |
1211 | * @param object $cm the coursemodule for this quiz | |
1212 | * | |
1213 | * @return int number of emails sent | |
1214 | */ | |
1215 | function quiz_send_notification_emails($course, $quiz, $attempt, $context, $cm) { | |
1216 | global $CFG, $USER; | |
1217 | // we will count goods and bads for error logging | |
46505ee7 | 1218 | $emailresult = array('good' => 0, 'fail' => 0); |
ac48e43a | 1219 | |
1220 | // do nothing if required objects not present | |
1221 | if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) { | |
1222 | debugging('quiz_send_notification_emails: Email(s) not sent due to program error.', | |
1223 | DEBUG_DEVELOPER); | |
1224 | return $emailresult['fail']; | |
1225 | } | |
1226 | ||
1227 | // check for confirmation required | |
1228 | $sendconfirm = false; | |
1229 | $notifyexcludeusers = ''; | |
25a03faa | 1230 | if (has_capability('mod/quiz:emailconfirmsubmission', $context, null, false)) { |
ac48e43a | 1231 | // exclude from notify emails later |
1232 | $notifyexcludeusers = $USER->id; | |
1233 | // send the email | |
1234 | $sendconfirm = true; | |
1235 | } | |
1236 | ||
1237 | // check for notifications required | |
25a03faa TH |
1238 | $notifyfields = 'u.id, u.username, u.firstname, u.lastname, u.email, u.lang, ' . |
1239 | 'u.timezone, u.mailformat, u.maildisplay'; | |
5a128471 | 1240 | $groups = groups_get_all_groups($course->id, $USER->id); |
1241 | if (is_array($groups) && count($groups) > 0) { | |
1242 | $groups = array_keys($groups); | |
255985f1 | 1243 | } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) { |
1244 | // If the user is not in a group, and the quiz is set to group mode, | |
1245 | // then set $gropus to a non-existant id so that only users with | |
1246 | // 'moodle/site:accessallgroups' get notified. | |
1247 | $groups = -1; | |
5a128471 | 1248 | } else { |
1249 | $groups = ''; | |
1250 | } | |
ac48e43a | 1251 | $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission', |
5a128471 | 1252 | $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true); |
ac48e43a | 1253 | |
1254 | // if something to send, then build $a | |
1255 | if (! empty($userstonotify) or $sendconfirm) { | |
0ff4bd08 | 1256 | $a = new stdClass(); |
ac48e43a | 1257 | // course info |
1258 | $a->coursename = $course->fullname; | |
1259 | $a->courseshortname = $course->shortname; | |
1260 | // quiz info | |
1261 | $a->quizname = $quiz->name; | |
a49cb927 | 1262 | $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id; |
25a03faa TH |
1263 | $a->quizreportlink = '<a href="' . $a->quizreporturl . '">' . |
1264 | format_string($quiz->name) . ' report</a>'; | |
ac48e43a | 1265 | $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id; |
25a03faa TH |
1266 | $a->quizreviewlink = '<a href="' . $a->quizreviewurl . '">' . |
1267 | format_string($quiz->name) . ' review</a>'; | |
a49cb927 | 1268 | $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; |
4f67bf8c | 1269 | $a->quizlink = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>'; |
ac48e43a | 1270 | // attempt info |
b0972c05 | 1271 | $a->submissiontime = userdate($attempt->timefinish); |
1272 | $a->timetaken = format_time($attempt->timefinish - $attempt->timestart); | |
ac48e43a | 1273 | // student who sat the quiz info |
1274 | $a->studentidnumber = $USER->idnumber; | |
1275 | $a->studentname = fullname($USER); | |
1276 | $a->studentusername = $USER->username; | |
1277 | } | |
1278 | ||
1279 | // send confirmation if required | |
1280 | if ($sendconfirm) { | |
1281 | // send the email and update stats | |
1282 | switch (quiz_send_confirmation($a)) { | |
1283 | case true: | |
1284 | $emailresult['good']++; | |
1285 | break; | |
1286 | case false: | |
1287 | $emailresult['fail']++; | |
1288 | break; | |
ac48e43a | 1289 | } |
1290 | } | |
1291 | ||
1292 | // send notifications if required | |
1293 | if (!empty($userstonotify)) { | |
1294 | // loop through recipients and send an email to each and update stats | |
1295 | foreach ($userstonotify as $recipient) { | |
1296 | switch (quiz_send_notification($recipient, $a)) { | |
1297 | case true: | |
1298 | $emailresult['good']++; | |
1299 | break; | |
1300 | case false: | |
1301 | $emailresult['fail']++; | |
1302 | break; | |
ac48e43a | 1303 | } |
1304 | } | |
1305 | } | |
1306 | ||
1307 | // log errors sending emails if any | |
1308 | if (! empty($emailresult['fail'])) { | |
25a03faa TH |
1309 | debugging('quiz_send_notification_emails:: ' . $emailresult['fail'] . |
1310 | ' email(s) failed to be sent.', DEBUG_DEVELOPER); | |
ac48e43a | 1311 | } |
ac48e43a | 1312 | |
1313 | // return the number of successfully sent emails | |
1314 | return $emailresult['good']; | |
1315 | } | |
fa583f5f | 1316 | |
7d4dfc48 | 1317 | /** |
1318 | * Checks if browser is safe browser | |
83192608 | 1319 | * |
7d4dfc48 | 1320 | * @return true, if browser is safe browser else false |
25302dee | 1321 | */ |
7d4dfc48 | 1322 | function quiz_check_safe_browser() { |
1323 | return strpos($_SERVER['HTTP_USER_AGENT'], "SEB") !== false; | |
1324 | } | |
ff065f96 TH |
1325 | |
1326 | function quiz_get_js_module() { | |
157434a5 | 1327 | global $PAGE; |
ff065f96 TH |
1328 | return array( |
1329 | 'name' => 'mod_quiz', | |
1330 | 'fullpath' => '/mod/quiz/module.js', | |
25a03faa TH |
1331 | 'requires' => array('base', 'dom', 'event-delegate', 'event-key', |
1332 | 'core_question_engine'), | |
ff065f96 TH |
1333 | 'strings' => array( |
1334 | array('timesup', 'quiz'), | |
1335 | array('functiondisabledbysecuremode', 'quiz'), | |
a26246ea | 1336 | array('flagged', 'question'), |
ff065f96 TH |
1337 | ), |
1338 | ); | |
fe6ce234 | 1339 | } |
25302dee TH |
1340 | |
1341 | ||
1342 | /** | |
1343 | * An extension of question_display_options that includes the extra options used | |
1344 | * by the quiz. | |
1345 | * | |
f7970e3c TH |
1346 | * @copyright 2010 The Open University |
1347 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
25302dee TH |
1348 | */ |
1349 | class mod_quiz_display_options extends question_display_options { | |
1350 | /**#@+ | |
1351 | * @var integer bits used to indicate various times in relation to a | |
1352 | * quiz attempt. | |
1353 | */ | |
1354 | const DURING = 0x10000; | |
1355 | const IMMEDIATELY_AFTER = 0x01000; | |
1356 | const LATER_WHILE_OPEN = 0x00100; | |
1357 | const AFTER_CLOSE = 0x00010; | |
1358 | /**#@-*/ | |
1359 | ||
1360 | /** | |
1361 | * @var boolean if this is false, then the student is not allowed to review | |
1362 | * anything about the attempt. | |
1363 | */ | |
1364 | public $attempt = true; | |
1365 | ||
1366 | /** | |
1367 | * @var boolean if this is false, then the student is not allowed to review | |
1368 | * anything about the attempt. | |
1369 | */ | |
1370 | public $overallfeedback = self::VISIBLE; | |
1371 | ||
1372 | /** | |
1373 | * Set up the various options from the quiz settings, and a time constant. | |
0ff4bd08 | 1374 | * @param object $quiz the quiz settings. |
f7970e3c | 1375 | * @param int $one of the {@link DURING}, {@link IMMEDIATELY_AFTER}, |
25302dee TH |
1376 | * {@link LATER_WHILE_OPEN} or {@link AFTER_CLOSE} constants. |
1377 | * @return mod_quiz_display_options set up appropriately. | |
1378 | */ | |
1379 | public static function make_from_quiz($quiz, $when) { | |
1380 | $options = new self(); | |
1381 | ||
1382 | $options->attempt = self::extract($quiz->reviewattempt, $when, true, false); | |
1383 | $options->correctness = self::extract($quiz->reviewcorrectness, $when); | |
25a03faa TH |
1384 | $options->marks = self::extract($quiz->reviewmarks, $when, |
1385 | self::MARK_AND_MAX, self::MAX_ONLY); | |
25302dee TH |
1386 | $options->feedback = self::extract($quiz->reviewspecificfeedback, $when); |
1387 | $options->generalfeedback = self::extract($quiz->reviewgeneralfeedback, $when); | |
1388 | $options->rightanswer = self::extract($quiz->reviewrightanswer, $when); | |
1389 | $options->overallfeedback = self::extract($quiz->reviewoverallfeedback, $when); | |
1390 | ||
1391 | $options->numpartscorrect = $options->feedback; | |
1392 | ||
1393 | if ($quiz->questiondecimalpoints != -1) { | |
1394 | $options->markdp = $quiz->questiondecimalpoints; | |
1395 | } else { | |
1396 | $options->markdp = $quiz->decimalpoints; | |
1397 | } | |
1398 | ||
1399 | return $options; | |
1400 | } | |
1401 | ||
25a03faa TH |
1402 | protected static function extract($bitmask, $bit, |
1403 | $whenset = self::VISIBLE, $whennotset = self::HIDDEN) { | |
25302dee TH |
1404 | if ($bitmask & $bit) { |
1405 | return $whenset; | |
1406 | } else { | |
1407 | return $whennotset; | |
1408 | } | |
1409 | } | |
1410 | } | |
1411 | ||
1412 | ||
1413 | /** | |
1414 | * A {@link qubaid_condition} for finding all the question usages belonging to | |
1415 | * a particular quiz. | |
1416 | * | |
f7970e3c TH |
1417 | * @copyright 2010 The Open University |
1418 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
25302dee | 1419 | */ |
6b5f24d3 | 1420 | class qubaids_for_quiz extends qubaid_join { |
25302dee | 1421 | public function __construct($quizid, $includepreviews = true, $onlyfinished = false) { |
6b5f24d3 | 1422 | $where = 'quiza.quiz = :quizaquiz'; |
25302dee TH |
1423 | if (!$includepreviews) { |
1424 | $where .= ' AND preview = 0'; | |
1425 | } | |
25302dee TH |
1426 | if ($onlyfinished) { |
1427 | $where .= ' AND timefinish <> 0'; | |
1428 | } | |
1429 | ||
6b5f24d3 TH |
1430 | parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, |
1431 | array('quizaquiz' => $quizid)); | |
25302dee TH |
1432 | } |
1433 | } |