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