1d5d8816e16f83b6155771ed43c1acb891ac6f3f
[moodle.git] / mod / quiz / locallib.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Library of functions used by the quiz module.
19  *
20  * This contains functions that are called from within the quiz module only
21  * Functions that are also called by core Moodle are in {@link lib.php}
22  * This script also loads the code in {@link questionlib.php} which holds
23  * the module-indpendent code for handling questions and which in turn
24  * initialises all the questiontype classes.
25  *
26  * @package    mod
27  * @subpackage quiz
28  * @copyright  1999 onwards Martin Dougiamas and others {@link http://moodle.com}
29  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
30  */
33 defined('MOODLE_INTERNAL') || die();
35 require_once($CFG->dirroot . '/mod/quiz/lib.php');
36 require_once($CFG->dirroot . '/mod/quiz/accessmanager.php');
37 require_once($CFG->dirroot . '/mod/quiz/accessmanager_form.php');
38 require_once($CFG->dirroot . '/mod/quiz/renderer.php');
39 require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
40 require_once($CFG->dirroot . '/question/editlib.php');
41 require_once($CFG->libdir  . '/eventslib.php');
42 require_once($CFG->libdir . '/filelib.php');
45 /**
46  * @var int We show the countdown timer if there is less than this amount of time left before the
47  * the quiz close date. (1 hour)
48  */
49 define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600');
51 /**
52  * @var int If there are fewer than this many seconds left when the student submits
53  * a page of the quiz, then do not take them to the next page of the quiz. Instead
54  * close the quiz immediately.
55  */
56 define('QUIZ_MIN_TIME_TO_CONTINUE', '2');
58 /**
59  * @var int We show no image when user selects No image from dropdown menu in quiz settings.
60  */
61 define('QUIZ_SHOWIMAGE_NONE', 0);
63 /**
64  * @var int We show small image when user selects small image from dropdown menu in quiz settings.
65  */
66 define('QUIZ_SHOWIMAGE_SMALL', 1);
68 /**
69  * @var int We show Large image when user selects Large image from dropdown menu in quiz settings.
70  */
71 define('QUIZ_SHOWIMAGE_LARGE', 2);
74 // Functions related to attempts ///////////////////////////////////////////////
76 /**
77  * Creates an object to represent a new attempt at a quiz
78  *
79  * Creates an attempt object to represent an attempt at the quiz by the current
80  * user starting at the current time. The ->id field is not set. The object is
81  * NOT written to the database.
82  *
83  * @param object $quizobj the quiz object to create an attempt for.
84  * @param int $attemptnumber the sequence number for the attempt.
85  * @param object $lastattempt the previous attempt by this user, if any. Only needed
86  *         if $attemptnumber > 1 and $quiz->attemptonlast is true.
87  * @param int $timenow the time the attempt was started at.
88  * @param bool $ispreview whether this new attempt is a preview.
89  *
90  * @return object the newly created attempt object.
91  */
92 function quiz_create_attempt(quiz $quizobj, $attemptnumber, $lastattempt, $timenow, $ispreview = false) {
93     global $USER;
95     $quiz = $quizobj->get_quiz();
96     if ($quiz->sumgrades < 0.000005 && $quiz->grade > 0.000005) {
97         throw new moodle_exception('cannotstartgradesmismatch', 'quiz',
98                 new moodle_url('/mod/quiz/view.php', array('q' => $quiz->id)),
99                     array('grade' => quiz_format_grade($quiz, $quiz->grade)));
100     }
102     if ($attemptnumber == 1 || !$quiz->attemptonlast) {
103         // We are not building on last attempt so create a new attempt.
104         $attempt = new stdClass();
105         $attempt->quiz = $quiz->id;
106         $attempt->userid = $USER->id;
107         $attempt->preview = 0;
108         $attempt->layout = quiz_clean_layout($quiz->questions, true);
109         if ($quiz->shufflequestions) {
110             $attempt->layout = quiz_repaginate($attempt->layout, $quiz->questionsperpage, true);
111         }
112     } else {
113         // Build on last attempt.
114         if (empty($lastattempt)) {
115             print_error('cannotfindprevattempt', 'quiz');
116         }
117         $attempt = $lastattempt;
118     }
120     $attempt->attempt = $attemptnumber;
121     $attempt->timestart = $timenow;
122     $attempt->timefinish = 0;
123     $attempt->timemodified = $timenow;
124     $attempt->state = quiz_attempt::IN_PROGRESS;
126     // If this is a preview, mark it as such.
127     if ($ispreview) {
128         $attempt->preview = 1;
129     }
131     $timeclose = $quizobj->get_access_manager($timenow)->get_end_time($attempt);
132     if ($timeclose === false || $ispreview) {
133         $attempt->timecheckstate = null;
134     } else {
135         $attempt->timecheckstate = $timeclose;
136     }
138     return $attempt;
140 /**
141  * Start a normal, new, quiz attempt.
142  *
143  * @param quiz                          $quizobj            the quiz object to start an attempt for.
144  * @param question_usage_by_activity    $quba
145  * @param object                        $attempt
146  * @param integer                       $attemptnumber      starting from 1
147  * @param integer                       $timenow            the attempt start time
148  * @return object                       modified attempt object
149  * @throws moodle_exception             if a random question exhausts the available questions
150  */
151 function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow) {
152     // Fully load all the questions in this quiz.
153     $quizobj->preload_questions();
154     $quizobj->load_questions();
156     // Add them all to the $quba.
157     $idstoslots = array();
158     $questionsinuse = array_keys($quizobj->get_questions());
159     foreach ($quizobj->get_questions() as $i => $questiondata) {
160         if ($questiondata->qtype != 'random') {
161             if (!$quizobj->get_quiz()->shuffleanswers) {
162                 $questiondata->options->shuffleanswers = false;
163             }
164             $question = question_bank::make_question($questiondata);
166         } else {
167             $question = question_bank::get_qtype('random')->choose_other_question(
168                 $questiondata, $questionsinuse, $quizobj->get_quiz()->shuffleanswers);
169             if (is_null($question)) {
170                 throw new moodle_exception('notenoughrandomquestions', 'quiz',
171                                            $quizobj->view_url(), $questiondata);
172             }
173         }
175         $idstoslots[$i] = $quba->add_question($question, $questiondata->maxmark);
176         $questionsinuse[] = $question->id;
177     }
179     // Start all the questions.
180     if ($attempt->preview) {
181         $variantoffset = rand(1, 100);
182     } else {
183         $variantoffset = $attemptnumber;
184     }
185     $quba->start_all_questions(
186         new question_variant_pseudorandom_no_repeats_strategy($variantoffset), $timenow);
188     // Update attempt layout.
189     $newlayout = array();
190     foreach (explode(',', $attempt->layout) as $qid) {
191         if ($qid != 0) {
192             $newlayout[] = $idstoslots[$qid];
193         } else {
194             $newlayout[] = 0;
195         }
196     }
197     $attempt->layout = implode(',', $newlayout);
198     return $attempt;
201 /**
202  * Start a subsequent new attempt, in each attempt builds on last mode.
203  *
204  * @param question_usage_by_activity    $quba         this question usage
205  * @param object                        $attempt      this attempt
206  * @param object                        $lastattempt  last attempt
207  * @return object                       modified attempt object
208  *
209  */
210 function quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt) {
211     $oldquba = question_engine::load_questions_usage_by_activity($lastattempt->uniqueid);
213     $oldnumberstonew = array();
214     foreach ($oldquba->get_attempt_iterator() as $oldslot => $oldqa) {
215         $newslot = $quba->add_question($oldqa->get_question(), $oldqa->get_max_mark());
217         $quba->start_question_based_on($newslot, $oldqa);
219         $oldnumberstonew[$oldslot] = $newslot;
220     }
222     // Update attempt layout.
223     $newlayout = array();
224     foreach (explode(',', $lastattempt->layout) as $oldslot) {
225         if ($oldslot != 0) {
226             $newlayout[] = $oldnumberstonew[$oldslot];
227         } else {
228             $newlayout[] = 0;
229         }
230     }
231     $attempt->layout = implode(',', $newlayout);
232     return $attempt;
235 /**
236  * The save started question usage and quiz attempt in db and log the started attempt.
237  *
238  * @param quiz                       $quizobj
239  * @param question_usage_by_activity $quba
240  * @param object                     $attempt
241  * @return object                    attempt object with uniqueid and id set.
242  */
243 function quiz_attempt_save_started($quizobj, $quba, $attempt) {
244     global $DB;
245     // Save the attempt in the database.
246     question_engine::save_questions_usage_by_activity($quba);
247     $attempt->uniqueid = $quba->get_id();
248     $attempt->id = $DB->insert_record('quiz_attempts', $attempt);
249     // Log the new attempt.
250     if ($attempt->preview) {
251         add_to_log($quizobj->get_courseid(), 'quiz', 'preview', 'view.php?id='.$quizobj->get_cmid(),
252                    $quizobj->get_quizid(), $quizobj->get_cmid());
253     } else {
254         add_to_log($quizobj->get_courseid(), 'quiz', 'attempt', 'review.php?attempt='.$attempt->id,
255                    $quizobj->get_quizid(), $quizobj->get_cmid());
256     }
257     return $attempt;
260 /**
261  * Fire an event to tell the rest of Moodle a quiz attempt has started.
262  *
263  * @param object $attempt
264  * @param quiz   $quizobj
265  */
266 function quiz_fire_attempt_started_event($attempt, $quizobj) {
267     // Trigger event.
268     $eventdata = new stdClass();
269     $eventdata->component = 'mod_quiz';
270     $eventdata->attemptid = $attempt->id;
271     $eventdata->timestart = $attempt->timestart;
272     $eventdata->timestamp = $attempt->timestart;
273     $eventdata->userid = $attempt->userid;
274     $eventdata->quizid = $quizobj->get_quizid();
275     $eventdata->cmid = $quizobj->get_cmid();
276     $eventdata->courseid = $quizobj->get_courseid();
277     events_trigger('quiz_attempt_started', $eventdata);
280 /**
281  * Returns an unfinished attempt (if there is one) for the given
282  * user on the given quiz. This function does not return preview attempts.
283  *
284  * @param int $quizid the id of the quiz.
285  * @param int $userid the id of the user.
286  *
287  * @return mixed the unfinished attempt if there is one, false if not.
288  */
289 function quiz_get_user_attempt_unfinished($quizid, $userid) {
290     $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true);
291     if ($attempts) {
292         return array_shift($attempts);
293     } else {
294         return false;
295     }
298 /**
299  * Delete a quiz attempt.
300  * @param mixed $attempt an integer attempt id or an attempt object
301  *      (row of the quiz_attempts table).
302  * @param object $quiz the quiz object.
303  */
304 function quiz_delete_attempt($attempt, $quiz) {
305     global $DB;
306     if (is_numeric($attempt)) {
307         if (!$attempt = $DB->get_record('quiz_attempts', array('id' => $attempt))) {
308             return;
309         }
310     }
312     if ($attempt->quiz != $quiz->id) {
313         debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " .
314                 "but was passed quiz $quiz->id.");
315         return;
316     }
318     question_engine::delete_questions_usage_by_activity($attempt->uniqueid);
319     $DB->delete_records('quiz_attempts', array('id' => $attempt->id));
321     // Search quiz_attempts for other instances by this user.
322     // If none, then delete record for this quiz, this user from quiz_grades
323     // else recalculate best grade.
324     $userid = $attempt->userid;
325     if (!$DB->record_exists('quiz_attempts', array('userid' => $userid, 'quiz' => $quiz->id))) {
326         $DB->delete_records('quiz_grades', array('userid' => $userid, 'quiz' => $quiz->id));
327     } else {
328         quiz_save_best_grade($quiz, $userid);
329     }
331     quiz_update_grades($quiz, $userid);
334 /**
335  * Delete all the preview attempts at a quiz, or possibly all the attempts belonging
336  * to one user.
337  * @param object $quiz the quiz object.
338  * @param int $userid (optional) if given, only delete the previews belonging to this user.
339  */
340 function quiz_delete_previews($quiz, $userid = null) {
341     global $DB;
342     $conditions = array('quiz' => $quiz->id, 'preview' => 1);
343     if (!empty($userid)) {
344         $conditions['userid'] = $userid;
345     }
346     $previewattempts = $DB->get_records('quiz_attempts', $conditions);
347     foreach ($previewattempts as $attempt) {
348         quiz_delete_attempt($attempt, $quiz);
349     }
352 /**
353  * @param int $quizid The quiz id.
354  * @return bool whether this quiz has any (non-preview) attempts.
355  */
356 function quiz_has_attempts($quizid) {
357     global $DB;
358     return $DB->record_exists('quiz_attempts', array('quiz' => $quizid, 'preview' => 0));
361 // Functions to do with quiz layout and pages //////////////////////////////////
363 /**
364  * Returns a comma separated list of question ids for the quiz
365  *
366  * @param string $layout The string representing the quiz layout. Each page is
367  *      represented as a comma separated list of question ids and 0 indicating
368  *      page breaks. So 5,2,0,3,0 means questions 5 and 2 on page 1 and question
369  *      3 on page 2
370  * @return string comma separated list of question ids, without page breaks.
371  */
372 function quiz_questions_in_quiz($layout) {
373     $questions = str_replace(',0', '', quiz_clean_layout($layout, true));
374     if ($questions === '0') {
375         return '';
376     } else {
377         return $questions;
378     }
381 /**
382  * Returns the number of pages in a quiz layout
383  *
384  * @param string $layout The string representing the quiz layout. Always ends in ,0
385  * @return int The number of pages in the quiz.
386  */
387 function quiz_number_of_pages($layout) {
388     return substr_count(',' . $layout, ',0');
391 /**
392  * Returns the number of questions in the quiz layout
393  *
394  * @param string $layout the string representing the quiz layout.
395  * @return int The number of questions in the quiz.
396  */
397 function quiz_number_of_questions_in_quiz($layout) {
398     $layout = quiz_questions_in_quiz(quiz_clean_layout($layout));
399     $count = substr_count($layout, ',');
400     if ($layout !== '') {
401         $count++;
402     }
403     return $count;
406 /**
407  * Re-paginates the quiz layout
408  *
409  * @param string $layout  The string representing the quiz layout. If there is
410  *      if there is any doubt about the quality of the input data, call
411  *      quiz_clean_layout before you call this function.
412  * @param int $perpage The number of questions per page
413  * @param bool $shuffle Should the questions be reordered randomly?
414  * @return string the new layout string
415  */
416 function quiz_repaginate($layout, $perpage, $shuffle = false) {
417     $questions = quiz_questions_in_quiz($layout);
418     if (!$questions) {
419         return '0';
420     }
422     $questions = explode(',', quiz_questions_in_quiz($layout));
423     if ($shuffle) {
424         shuffle($questions);
425     }
427     $onthispage = 0;
428     $layout = array();
429     foreach ($questions as $question) {
430         if ($perpage and $onthispage >= $perpage) {
431             $layout[] = 0;
432             $onthispage = 0;
433         }
434         $layout[] = $question;
435         $onthispage += 1;
436     }
438     $layout[] = 0;
439     return implode(',', $layout);
442 // Functions to do with quiz grades ////////////////////////////////////////////
444 /**
445  * Creates an array of maximum grades for a quiz
446  *
447  * The grades are extracted from the quiz_question_instances table.
448  * @param object $quiz The quiz settings.
449  * @return array of grades indexed by question id. These are the maximum
450  *      possible grades that students can achieve for each of the questions.
451  */
452 function quiz_get_all_question_grades($quiz) {
453     global $CFG, $DB;
455     $questionlist = quiz_questions_in_quiz($quiz->questions);
456     if (empty($questionlist)) {
457         return array();
458     }
460     $params = array($quiz->id);
461     $wheresql = '';
462     if (!is_null($questionlist)) {
463         list($usql, $question_params) = $DB->get_in_or_equal(explode(',', $questionlist));
464         $wheresql = " AND question $usql ";
465         $params = array_merge($params, $question_params);
466     }
468     $instances = $DB->get_records_sql("SELECT question, grade, id
469                                     FROM {quiz_question_instances}
470                                     WHERE quiz = ? $wheresql", $params);
472     $list = explode(",", $questionlist);
473     $grades = array();
475     foreach ($list as $qid) {
476         if (isset($instances[$qid])) {
477             $grades[$qid] = $instances[$qid]->grade;
478         } else {
479             $grades[$qid] = 1;
480         }
481     }
482     return $grades;
485 /**
486  * Convert the raw grade stored in $attempt into a grade out of the maximum
487  * grade for this quiz.
488  *
489  * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades
490  * @param object $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used.
491  * @param bool|string $format whether to format the results for display
492  *      or 'question' to format a question grade (different number of decimal places.
493  * @return float|string the rescaled grade, or null/the lang string 'notyetgraded'
494  *      if the $grade is null.
495  */
496 function quiz_rescale_grade($rawgrade, $quiz, $format = true) {
497     if (is_null($rawgrade)) {
498         $grade = null;
499     } else if ($quiz->sumgrades >= 0.000005) {
500         $grade = $rawgrade * $quiz->grade / $quiz->sumgrades;
501     } else {
502         $grade = 0;
503     }
504     if ($format === 'question') {
505         $grade = quiz_format_question_grade($quiz, $grade);
506     } else if ($format) {
507         $grade = quiz_format_grade($quiz, $grade);
508     }
509     return $grade;
512 /**
513  * Get the feedback text that should be show to a student who
514  * got this grade on this quiz. The feedback is processed ready for diplay.
515  *
516  * @param float $grade a grade on this quiz.
517  * @param object $quiz the quiz settings.
518  * @param object $context the quiz context.
519  * @return string the comment that corresponds to this grade (empty string if there is not one.
520  */
521 function quiz_feedback_for_grade($grade, $quiz, $context) {
522     global $DB;
524     if (is_null($grade)) {
525         return '';
526     }
528     // With CBM etc, it is possible to get -ve grades, which would then not match
529     // any feedback. Therefore, we replace -ve grades with 0.
530     $grade = max($grade, 0);
532     $feedback = $DB->get_record_select('quiz_feedback',
533             'quizid = ? AND mingrade <= ? AND ? < maxgrade', array($quiz->id, $grade, $grade));
535     if (empty($feedback->feedbacktext)) {
536         return '';
537     }
539     // Clean the text, ready for display.
540     $formatoptions = new stdClass();
541     $formatoptions->noclean = true;
542     $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext, 'pluginfile.php',
543             $context->id, 'mod_quiz', 'feedback', $feedback->id);
544     $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions);
546     return $feedbacktext;
549 /**
550  * @param object $quiz the quiz database row.
551  * @return bool Whether this quiz has any non-blank feedback text.
552  */
553 function quiz_has_feedback($quiz) {
554     global $DB;
555     static $cache = array();
556     if (!array_key_exists($quiz->id, $cache)) {
557         $cache[$quiz->id] = quiz_has_grades($quiz) &&
558                 $DB->record_exists_select('quiz_feedback', "quizid = ? AND " .
559                     $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true),
560                 array($quiz->id));
561     }
562     return $cache[$quiz->id];
565 /**
566  * Update the sumgrades field of the quiz. This needs to be called whenever
567  * the grading structure of the quiz is changed. For example if a question is
568  * added or removed, or a question weight is changed.
569  *
570  * You should call {@link quiz_delete_previews()} before you call this function.
571  *
572  * @param object $quiz a quiz.
573  */
574 function quiz_update_sumgrades($quiz) {
575     global $DB;
577     $sql = 'UPDATE {quiz}
578             SET sumgrades = COALESCE((
579                 SELECT SUM(grade)
580                 FROM {quiz_question_instances}
581                 WHERE quiz = {quiz}.id
582             ), 0)
583             WHERE id = ?';
584     $DB->execute($sql, array($quiz->id));
585     $quiz->sumgrades = $DB->get_field('quiz', 'sumgrades', array('id' => $quiz->id));
587     if ($quiz->sumgrades < 0.000005 && quiz_has_attempts($quiz->id)) {
588         // If the quiz has been attempted, and the sumgrades has been
589         // set to 0, then we must also set the maximum possible grade to 0, or
590         // we will get a divide by zero error.
591         quiz_set_grade(0, $quiz);
592     }
595 /**
596  * Update the sumgrades field of the attempts at a quiz.
597  *
598  * @param object $quiz a quiz.
599  */
600 function quiz_update_all_attempt_sumgrades($quiz) {
601     global $DB;
602     $dm = new question_engine_data_mapper();
603     $timenow = time();
605     $sql = "UPDATE {quiz_attempts}
606             SET
607                 timemodified = :timenow,
608                 sumgrades = (
609                     {$dm->sum_usage_marks_subquery('uniqueid')}
610                 )
611             WHERE quiz = :quizid AND state = :finishedstate";
612     $DB->execute($sql, array('timenow' => $timenow, 'quizid' => $quiz->id,
613             'finishedstate' => quiz_attempt::FINISHED));
616 /**
617  * The quiz grade is the maximum that student's results are marked out of. When it
618  * changes, the corresponding data in quiz_grades and quiz_feedback needs to be
619  * rescaled. After calling this function, you probably need to call
620  * quiz_update_all_attempt_sumgrades, quiz_update_all_final_grades and
621  * quiz_update_grades.
622  *
623  * @param float $newgrade the new maximum grade for the quiz.
624  * @param object $quiz the quiz we are updating. Passed by reference so its
625  *      grade field can be updated too.
626  * @return bool indicating success or failure.
627  */
628 function quiz_set_grade($newgrade, $quiz) {
629     global $DB;
630     // This is potentially expensive, so only do it if necessary.
631     if (abs($quiz->grade - $newgrade) < 1e-7) {
632         // Nothing to do.
633         return true;
634     }
636     $oldgrade = $quiz->grade;
637     $quiz->grade = $newgrade;
639     // Use a transaction, so that on those databases that support it, this is safer.
640     $transaction = $DB->start_delegated_transaction();
642     // Update the quiz table.
643     $DB->set_field('quiz', 'grade', $newgrade, array('id' => $quiz->instance));
645     if ($oldgrade < 1) {
646         // If the old grade was zero, we cannot rescale, we have to recompute.
647         // We also recompute if the old grade was too small to avoid underflow problems.
648         quiz_update_all_final_grades($quiz);
650     } else {
651         // We can rescale the grades efficiently.
652         $timemodified = time();
653         $DB->execute("
654                 UPDATE {quiz_grades}
655                 SET grade = ? * grade, timemodified = ?
656                 WHERE quiz = ?
657         ", array($newgrade/$oldgrade, $timemodified, $quiz->id));
658     }
660     if ($oldgrade > 1e-7) {
661         // Update the quiz_feedback table.
662         $factor = $newgrade/$oldgrade;
663         $DB->execute("
664                 UPDATE {quiz_feedback}
665                 SET mingrade = ? * mingrade, maxgrade = ? * maxgrade
666                 WHERE quizid = ?
667         ", array($factor, $factor, $quiz->id));
668     }
670     // Update grade item and send all grades to gradebook.
671     quiz_grade_item_update($quiz);
672     quiz_update_grades($quiz);
674     $transaction->allow_commit();
675     return true;
678 /**
679  * Save the overall grade for a user at a quiz in the quiz_grades table
680  *
681  * @param object $quiz The quiz for which the best grade is to be calculated and then saved.
682  * @param int $userid The userid to calculate the grade for. Defaults to the current user.
683  * @param array $attempts The attempts of this user. Useful if you are
684  * looping through many users. Attempts can be fetched in one master query to
685  * avoid repeated querying.
686  * @return bool Indicates success or failure.
687  */
688 function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) {
689     global $DB, $OUTPUT, $USER;
691     if (empty($userid)) {
692         $userid = $USER->id;
693     }
695     if (!$attempts) {
696         // Get all the attempts made by the user.
697         $attempts = quiz_get_user_attempts($quiz->id, $userid);
698     }
700     // Calculate the best grade.
701     $bestgrade = quiz_calculate_best_grade($quiz, $attempts);
702     $bestgrade = quiz_rescale_grade($bestgrade, $quiz, false);
704     // Save the best grade in the database.
705     if (is_null($bestgrade)) {
706         $DB->delete_records('quiz_grades', array('quiz' => $quiz->id, 'userid' => $userid));
708     } else if ($grade = $DB->get_record('quiz_grades',
709             array('quiz' => $quiz->id, 'userid' => $userid))) {
710         $grade->grade = $bestgrade;
711         $grade->timemodified = time();
712         $DB->update_record('quiz_grades', $grade);
714     } else {
715         $grade = new stdClass();
716         $grade->quiz = $quiz->id;
717         $grade->userid = $userid;
718         $grade->grade = $bestgrade;
719         $grade->timemodified = time();
720         $DB->insert_record('quiz_grades', $grade);
721     }
723     quiz_update_grades($quiz, $userid);
726 /**
727  * Calculate the overall grade for a quiz given a number of attempts by a particular user.
728  *
729  * @param object $quiz    the quiz settings object.
730  * @param array $attempts an array of all the user's attempts at this quiz in order.
731  * @return float          the overall grade
732  */
733 function quiz_calculate_best_grade($quiz, $attempts) {
735     switch ($quiz->grademethod) {
737         case QUIZ_ATTEMPTFIRST:
738             $firstattempt = reset($attempts);
739             return $firstattempt->sumgrades;
741         case QUIZ_ATTEMPTLAST:
742             $lastattempt = end($attempts);
743             return $lastattempt->sumgrades;
745         case QUIZ_GRADEAVERAGE:
746             $sum = 0;
747             $count = 0;
748             foreach ($attempts as $attempt) {
749                 if (!is_null($attempt->sumgrades)) {
750                     $sum += $attempt->sumgrades;
751                     $count++;
752                 }
753             }
754             if ($count == 0) {
755                 return null;
756             }
757             return $sum / $count;
759         case QUIZ_GRADEHIGHEST:
760         default:
761             $max = null;
762             foreach ($attempts as $attempt) {
763                 if ($attempt->sumgrades > $max) {
764                     $max = $attempt->sumgrades;
765                 }
766             }
767             return $max;
768     }
771 /**
772  * Update the final grade at this quiz for all students.
773  *
774  * This function is equivalent to calling quiz_save_best_grade for all
775  * users, but much more efficient.
776  *
777  * @param object $quiz the quiz settings.
778  */
779 function quiz_update_all_final_grades($quiz) {
780     global $DB;
782     if (!$quiz->sumgrades) {
783         return;
784     }
786     $param = array('iquizid' => $quiz->id, 'istatefinished' => quiz_attempt::FINISHED);
787     $firstlastattemptjoin = "JOIN (
788             SELECT
789                 iquiza.userid,
790                 MIN(attempt) AS firstattempt,
791                 MAX(attempt) AS lastattempt
793             FROM {quiz_attempts} iquiza
795             WHERE
796                 iquiza.state = :istatefinished AND
797                 iquiza.preview = 0 AND
798                 iquiza.quiz = :iquizid
800             GROUP BY iquiza.userid
801         ) first_last_attempts ON first_last_attempts.userid = quiza.userid";
803     switch ($quiz->grademethod) {
804         case QUIZ_ATTEMPTFIRST:
805             // Because of the where clause, there will only be one row, but we
806             // must still use an aggregate function.
807             $select = 'MAX(quiza.sumgrades)';
808             $join = $firstlastattemptjoin;
809             $where = 'quiza.attempt = first_last_attempts.firstattempt AND';
810             break;
812         case QUIZ_ATTEMPTLAST:
813             // Because of the where clause, there will only be one row, but we
814             // must still use an aggregate function.
815             $select = 'MAX(quiza.sumgrades)';
816             $join = $firstlastattemptjoin;
817             $where = 'quiza.attempt = first_last_attempts.lastattempt AND';
818             break;
820         case QUIZ_GRADEAVERAGE:
821             $select = 'AVG(quiza.sumgrades)';
822             $join = '';
823             $where = '';
824             break;
826         default:
827         case QUIZ_GRADEHIGHEST:
828             $select = 'MAX(quiza.sumgrades)';
829             $join = '';
830             $where = '';
831             break;
832     }
834     if ($quiz->sumgrades >= 0.000005) {
835         $finalgrade = $select . ' * ' . ($quiz->grade / $quiz->sumgrades);
836     } else {
837         $finalgrade = '0';
838     }
839     $param['quizid'] = $quiz->id;
840     $param['quizid2'] = $quiz->id;
841     $param['quizid3'] = $quiz->id;
842     $param['quizid4'] = $quiz->id;
843     $param['statefinished'] = quiz_attempt::FINISHED;
844     $param['statefinished2'] = quiz_attempt::FINISHED;
845     $finalgradesubquery = "
846             SELECT quiza.userid, $finalgrade AS newgrade
847             FROM {quiz_attempts} quiza
848             $join
849             WHERE
850                 $where
851                 quiza.state = :statefinished AND
852                 quiza.preview = 0 AND
853                 quiza.quiz = :quizid3
854             GROUP BY quiza.userid";
856     $changedgrades = $DB->get_records_sql("
857             SELECT users.userid, qg.id, qg.grade, newgrades.newgrade
859             FROM (
860                 SELECT userid
861                 FROM {quiz_grades} qg
862                 WHERE quiz = :quizid
863             UNION
864                 SELECT DISTINCT userid
865                 FROM {quiz_attempts} quiza2
866                 WHERE
867                     quiza2.state = :statefinished2 AND
868                     quiza2.preview = 0 AND
869                     quiza2.quiz = :quizid2
870             ) users
872             LEFT JOIN {quiz_grades} qg ON qg.userid = users.userid AND qg.quiz = :quizid4
874             LEFT JOIN (
875                 $finalgradesubquery
876             ) newgrades ON newgrades.userid = users.userid
878             WHERE
879                 ABS(newgrades.newgrade - qg.grade) > 0.000005 OR
880                 ((newgrades.newgrade IS NULL OR qg.grade IS NULL) AND NOT
881                           (newgrades.newgrade IS NULL AND qg.grade IS NULL))",
882                 // The mess on the previous line is detecting where the value is
883                 // NULL in one column, and NOT NULL in the other, but SQL does
884                 // not have an XOR operator, and MS SQL server can't cope with
885                 // (newgrades.newgrade IS NULL) <> (qg.grade IS NULL).
886             $param);
888     $timenow = time();
889     $todelete = array();
890     foreach ($changedgrades as $changedgrade) {
892         if (is_null($changedgrade->newgrade)) {
893             $todelete[] = $changedgrade->userid;
895         } else if (is_null($changedgrade->grade)) {
896             $toinsert = new stdClass();
897             $toinsert->quiz = $quiz->id;
898             $toinsert->userid = $changedgrade->userid;
899             $toinsert->timemodified = $timenow;
900             $toinsert->grade = $changedgrade->newgrade;
901             $DB->insert_record('quiz_grades', $toinsert);
903         } else {
904             $toupdate = new stdClass();
905             $toupdate->id = $changedgrade->id;
906             $toupdate->grade = $changedgrade->newgrade;
907             $toupdate->timemodified = $timenow;
908             $DB->update_record('quiz_grades', $toupdate);
909         }
910     }
912     if (!empty($todelete)) {
913         list($test, $params) = $DB->get_in_or_equal($todelete);
914         $DB->delete_records_select('quiz_grades', 'quiz = ? AND userid ' . $test,
915                 array_merge(array($quiz->id), $params));
916     }
919 /**
920  * Efficiently update check state time on all open attempts
921  *
922  * @param array $conditions optional restrictions on which attempts to update
923  *                    Allowed conditions:
924  *                      courseid => (array|int) attempts in given course(s)
925  *                      userid   => (array|int) attempts for given user(s)
926  *                      quizid   => (array|int) attempts in given quiz(s)
927  *                      groupid  => (array|int) quizzes with some override for given group(s)
928  *
929  */
930 function quiz_update_open_attempts(array $conditions) {
931     global $DB;
933     foreach ($conditions as &$value) {
934         if (!is_array($value)) {
935             $value = array($value);
936         }
937     }
939     $params = array();
940     $wheres = array("quiza.state IN ('inprogress', 'overdue')");
941     $iwheres = array("iquiza.state IN ('inprogress', 'overdue')");
943     if (isset($conditions['courseid'])) {
944         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'cid');
945         $params = array_merge($params, $inparams);
946         $wheres[] = "quiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)";
947         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'icid');
948         $params = array_merge($params, $inparams);
949         $iwheres[] = "iquiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)";
950     }
952     if (isset($conditions['userid'])) {
953         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'uid');
954         $params = array_merge($params, $inparams);
955         $wheres[] = "quiza.userid $incond";
956         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'iuid');
957         $params = array_merge($params, $inparams);
958         $iwheres[] = "iquiza.userid $incond";
959     }
961     if (isset($conditions['quizid'])) {
962         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'qid');
963         $params = array_merge($params, $inparams);
964         $wheres[] = "quiza.quiz $incond";
965         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'iqid');
966         $params = array_merge($params, $inparams);
967         $iwheres[] = "iquiza.quiz $incond";
968     }
970     if (isset($conditions['groupid'])) {
971         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'gid');
972         $params = array_merge($params, $inparams);
973         $wheres[] = "quiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)";
974         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'igid');
975         $params = array_merge($params, $inparams);
976         $iwheres[] = "iquiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)";
977     }
979     // SQL to compute timeclose and timelimit for each attempt:
980     $quizausersql = quiz_get_attempt_usertime_sql(
981             implode("\n                AND ", $iwheres));
983     // SQL to compute the new timecheckstate
984     $timecheckstatesql = "
985           CASE WHEN quizauser.usertimelimit = 0 AND quizauser.usertimeclose = 0 THEN NULL
986                WHEN quizauser.usertimelimit = 0 THEN quizauser.usertimeclose
987                WHEN quizauser.usertimeclose = 0 THEN quiza.timestart + quizauser.usertimelimit
988                WHEN quiza.timestart + quizauser.usertimelimit < quizauser.usertimeclose THEN quiza.timestart + quizauser.usertimelimit
989                ELSE quizauser.usertimeclose END +
990           CASE WHEN quiza.state = 'overdue' THEN quiz.graceperiod ELSE 0 END";
992     // SQL to select which attempts to process
993     $attemptselect = implode("\n                         AND ", $wheres);
995    /*
996     * Each database handles updates with inner joins differently:
997     *  - mysql does not allow a FROM clause
998     *  - postgres and mssql allow FROM but handle table aliases differently
999     *  - oracle requires a subquery
1000     *
1001     * Different code for each database.
1002     */
1004     $dbfamily = $DB->get_dbfamily();
1005     if ($dbfamily == 'mysql') {
1006         $updatesql = "UPDATE {quiz_attempts} quiza
1007                         JOIN {quiz} quiz ON quiz.id = quiza.quiz
1008                         JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id
1009                          SET quiza.timecheckstate = $timecheckstatesql
1010                        WHERE $attemptselect";
1011     } else if ($dbfamily == 'postgres') {
1012         $updatesql = "UPDATE {quiz_attempts} quiza
1013                          SET timecheckstate = $timecheckstatesql
1014                         FROM {quiz} quiz, ( $quizausersql ) quizauser
1015                        WHERE quiz.id = quiza.quiz
1016                          AND quizauser.id = quiza.id
1017                          AND $attemptselect";
1018     } else if ($dbfamily == 'mssql') {
1019         $updatesql = "UPDATE quiza
1020                          SET timecheckstate = $timecheckstatesql
1021                         FROM {quiz_attempts} quiza
1022                         JOIN {quiz} quiz ON quiz.id = quiza.quiz
1023                         JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id
1024                        WHERE $attemptselect";
1025     } else {
1026         // oracle, sqlite and others
1027         $updatesql = "UPDATE {quiz_attempts} quiza
1028                          SET timecheckstate = (
1029                            SELECT $timecheckstatesql
1030                              FROM {quiz} quiz, ( $quizausersql ) quizauser
1031                             WHERE quiz.id = quiza.quiz
1032                               AND quizauser.id = quiza.id
1033                          )
1034                          WHERE $attemptselect";
1035     }
1037     $DB->execute($updatesql, $params);
1040 /**
1041  * Returns SQL to compute timeclose and timelimit for every attempt, taking into account user and group overrides.
1042  *
1043  * @param string $redundantwhereclauses extra where clauses to add to the subquery
1044  *      for performance. These can use the table alias iquiza for the quiz attempts table.
1045  * @return string SQL select with columns attempt.id, usertimeclose, usertimelimit.
1046  */
1047 function quiz_get_attempt_usertime_sql($redundantwhereclauses = '') {
1048     if ($redundantwhereclauses) {
1049         $redundantwhereclauses = 'WHERE ' . $redundantwhereclauses;
1050     }
1051     // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede
1052     // any other group override
1053     $quizausersql = "
1054           SELECT iquiza.id,
1055            COALESCE(MAX(quo.timeclose), MAX(qgo1.timeclose), MAX(qgo2.timeclose), iquiz.timeclose) AS usertimeclose,
1056            COALESCE(MAX(quo.timelimit), MAX(qgo3.timelimit), MAX(qgo4.timelimit), iquiz.timelimit) AS usertimelimit
1058            FROM {quiz_attempts} iquiza
1059            JOIN {quiz} iquiz ON iquiz.id = iquiza.quiz
1060       LEFT JOIN {quiz_overrides} quo ON quo.quiz = iquiza.quiz AND quo.userid = iquiza.userid
1061       LEFT JOIN {groups_members} gm ON gm.userid = iquiza.userid
1062       LEFT JOIN {quiz_overrides} qgo1 ON qgo1.quiz = iquiza.quiz AND qgo1.groupid = gm.groupid AND qgo1.timeclose = 0
1063       LEFT JOIN {quiz_overrides} qgo2 ON qgo2.quiz = iquiza.quiz AND qgo2.groupid = gm.groupid AND qgo2.timeclose > 0
1064       LEFT JOIN {quiz_overrides} qgo3 ON qgo3.quiz = iquiza.quiz AND qgo3.groupid = gm.groupid AND qgo3.timelimit = 0
1065       LEFT JOIN {quiz_overrides} qgo4 ON qgo4.quiz = iquiza.quiz AND qgo4.groupid = gm.groupid AND qgo4.timelimit > 0
1066           $redundantwhereclauses
1067        GROUP BY iquiza.id, iquiz.id, iquiz.timeclose, iquiz.timelimit";
1068     return $quizausersql;
1071 /**
1072  * Return the attempt with the best grade for a quiz
1073  *
1074  * Which attempt is the best depends on $quiz->grademethod. If the grade
1075  * method is GRADEAVERAGE then this function simply returns the last attempt.
1076  * @return object         The attempt with the best grade
1077  * @param object $quiz    The quiz for which the best grade is to be calculated
1078  * @param array $attempts An array of all the attempts of the user at the quiz
1079  */
1080 function quiz_calculate_best_attempt($quiz, $attempts) {
1082     switch ($quiz->grademethod) {
1084         case QUIZ_ATTEMPTFIRST:
1085             foreach ($attempts as $attempt) {
1086                 return $attempt;
1087             }
1088             break;
1090         case QUIZ_GRADEAVERAGE: // We need to do something with it.
1091         case QUIZ_ATTEMPTLAST:
1092             foreach ($attempts as $attempt) {
1093                 $final = $attempt;
1094             }
1095             return $final;
1097         default:
1098         case QUIZ_GRADEHIGHEST:
1099             $max = -1;
1100             foreach ($attempts as $attempt) {
1101                 if ($attempt->sumgrades > $max) {
1102                     $max = $attempt->sumgrades;
1103                     $maxattempt = $attempt;
1104                 }
1105             }
1106             return $maxattempt;
1107     }
1110 /**
1111  * @return array int => lang string the options for calculating the quiz grade
1112  *      from the individual attempt grades.
1113  */
1114 function quiz_get_grading_options() {
1115     return array(
1116         QUIZ_GRADEHIGHEST => get_string('gradehighest', 'quiz'),
1117         QUIZ_GRADEAVERAGE => get_string('gradeaverage', 'quiz'),
1118         QUIZ_ATTEMPTFIRST => get_string('attemptfirst', 'quiz'),
1119         QUIZ_ATTEMPTLAST  => get_string('attemptlast', 'quiz')
1120     );
1123 /**
1124  * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE,
1125  *      QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST.
1126  * @return the lang string for that option.
1127  */
1128 function quiz_get_grading_option_name($option) {
1129     $strings = quiz_get_grading_options();
1130     return $strings[$option];
1133 /**
1134  * @return array string => lang string the options for handling overdue quiz
1135  *      attempts.
1136  */
1137 function quiz_get_overdue_handling_options() {
1138     return array(
1139         'autosubmit'  => get_string('overduehandlingautosubmit', 'quiz'),
1140         'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'),
1141         'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'),
1142     );
1145 /**
1146  * @param string $state one of the state constants like IN_PROGRESS.
1147  * @return string the human-readable state name.
1148  */
1149 function quiz_attempt_state_name($state) {
1150     switch ($state) {
1151         case quiz_attempt::IN_PROGRESS:
1152             return get_string('stateinprogress', 'quiz');
1153         case quiz_attempt::OVERDUE:
1154             return get_string('stateoverdue', 'quiz');
1155         case quiz_attempt::FINISHED:
1156             return get_string('statefinished', 'quiz');
1157         case quiz_attempt::ABANDONED:
1158             return get_string('stateabandoned', 'quiz');
1159         default:
1160             throw new coding_exception('Unknown quiz attempt state.');
1161     }
1164 // Other quiz functions ////////////////////////////////////////////////////////
1166 /**
1167  * @param object $quiz the quiz.
1168  * @param int $cmid the course_module object for this quiz.
1169  * @param object $question the question.
1170  * @param string $returnurl url to return to after action is done.
1171  * @return string html for a number of icons linked to action pages for a
1172  * question - preview and edit / view icons depending on user capabilities.
1173  */
1174 function quiz_question_action_icons($quiz, $cmid, $question, $returnurl) {
1175     $html = quiz_question_preview_button($quiz, $question) . ' ' .
1176             quiz_question_edit_button($cmid, $question, $returnurl);
1177     return $html;
1180 /**
1181  * @param int $cmid the course_module.id for this quiz.
1182  * @param object $question the question.
1183  * @param string $returnurl url to return to after action is done.
1184  * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon.
1185  * @return the HTML for an edit icon, view icon, or nothing for a question
1186  *      (depending on permissions).
1187  */
1188 function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') {
1189     global $CFG, $OUTPUT;
1191     // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page.
1192     static $stredit = null;
1193     static $strview = null;
1194     if ($stredit === null) {
1195         $stredit = get_string('edit');
1196         $strview = get_string('view');
1197     }
1199     // What sort of icon should we show?
1200     $action = '';
1201     if (!empty($question->id) &&
1202             (question_has_capability_on($question, 'edit', $question->category) ||
1203                     question_has_capability_on($question, 'move', $question->category))) {
1204         $action = $stredit;
1205         $icon = '/t/edit';
1206     } else if (!empty($question->id) &&
1207             question_has_capability_on($question, 'view', $question->category)) {
1208         $action = $strview;
1209         $icon = '/i/info';
1210     }
1212     // Build the icon.
1213     if ($action) {
1214         if ($returnurl instanceof moodle_url) {
1215             $returnurl = $returnurl->out_as_local_url(false);
1216         }
1217         $questionparams = array('returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id);
1218         $questionurl = new moodle_url("$CFG->wwwroot/question/question.php", $questionparams);
1219         return '<a title="' . $action . '" href="' . $questionurl->out() . '" class="questioneditbutton"><img src="' .
1220                 $OUTPUT->pix_url($icon) . '" alt="' . $action . '" />' . $contentaftericon .
1221                 '</a>';
1222     } else if ($contentaftericon) {
1223         return '<span class="questioneditbutton">' . $contentaftericon . '</span>';
1224     } else {
1225         return '';
1226     }
1229 /**
1230  * @param object $quiz the quiz settings
1231  * @param object $question the question
1232  * @return moodle_url to preview this question with the options from this quiz.
1233  */
1234 function quiz_question_preview_url($quiz, $question) {
1235     // Get the appropriate display options.
1236     $displayoptions = mod_quiz_display_options::make_from_quiz($quiz,
1237             mod_quiz_display_options::DURING);
1239     $maxmark = null;
1240     if (isset($question->maxmark)) {
1241         $maxmark = $question->maxmark;
1242     }
1244     // Work out the correcte preview URL.
1245     return question_preview_url($question->id, $quiz->preferredbehaviour,
1246             $maxmark, $displayoptions);
1249 /**
1250  * @param object $quiz the quiz settings
1251  * @param object $question the question
1252  * @param bool $label if true, show the preview question label after the icon
1253  * @return the HTML for a preview question icon.
1254  */
1255 function quiz_question_preview_button($quiz, $question, $label = false) {
1256     global $CFG, $OUTPUT;
1257     if (!question_has_capability_on($question, 'use', $question->category)) {
1258         return '';
1259     }
1261     $url = quiz_question_preview_url($quiz, $question);
1263     // Do we want a label?
1264     $strpreviewlabel = '';
1265     if ($label) {
1266         $strpreviewlabel = get_string('preview', 'quiz');
1267     }
1269     // Build the icon.
1270     $strpreviewquestion = get_string('previewquestion', 'quiz');
1271     $image = $OUTPUT->pix_icon('t/preview', $strpreviewquestion);
1273     $action = new popup_action('click', $url, 'questionpreview',
1274             question_preview_popup_params());
1276     return $OUTPUT->action_link($url, $image, $action, array('title' => $strpreviewquestion));
1279 /**
1280  * @param object $attempt the attempt.
1281  * @param object $context the quiz context.
1282  * @return int whether flags should be shown/editable to the current user for this attempt.
1283  */
1284 function quiz_get_flag_option($attempt, $context) {
1285     global $USER;
1286     if (!has_capability('moodle/question:flag', $context)) {
1287         return question_display_options::HIDDEN;
1288     } else if ($attempt->userid == $USER->id) {
1289         return question_display_options::EDITABLE;
1290     } else {
1291         return question_display_options::VISIBLE;
1292     }
1295 /**
1296  * Work out what state this quiz attempt is in - in the sense used by
1297  * quiz_get_review_options, not in the sense of $attempt->state.
1298  * @param object $quiz the quiz settings
1299  * @param object $attempt the quiz_attempt database row.
1300  * @return int one of the mod_quiz_display_options::DURING,
1301  *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
1302  */
1303 function quiz_attempt_state($quiz, $attempt) {
1304     if ($attempt->state == quiz_attempt::IN_PROGRESS) {
1305         return mod_quiz_display_options::DURING;
1306     } else if (time() < $attempt->timefinish + 120) {
1307         return mod_quiz_display_options::IMMEDIATELY_AFTER;
1308     } else if (!$quiz->timeclose || time() < $quiz->timeclose) {
1309         return mod_quiz_display_options::LATER_WHILE_OPEN;
1310     } else {
1311         return mod_quiz_display_options::AFTER_CLOSE;
1312     }
1315 /**
1316  * The the appropraite mod_quiz_display_options object for this attempt at this
1317  * quiz right now.
1318  *
1319  * @param object $quiz the quiz instance.
1320  * @param object $attempt the attempt in question.
1321  * @param $context the quiz context.
1322  *
1323  * @return mod_quiz_display_options
1324  */
1325 function quiz_get_review_options($quiz, $attempt, $context) {
1326     $options = mod_quiz_display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt));
1328     $options->readonly = true;
1329     $options->flags = quiz_get_flag_option($attempt, $context);
1330     if (!empty($attempt->id)) {
1331         $options->questionreviewlink = new moodle_url('/mod/quiz/reviewquestion.php',
1332                 array('attempt' => $attempt->id));
1333     }
1335     // Show a link to the comment box only for closed attempts.
1336     if (!empty($attempt->id) && $attempt->state == quiz_attempt::FINISHED && !$attempt->preview &&
1337             !is_null($context) && has_capability('mod/quiz:grade', $context)) {
1338         $options->manualcomment = question_display_options::VISIBLE;
1339         $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php',
1340                 array('attempt' => $attempt->id));
1341     }
1343     if (!is_null($context) && !$attempt->preview &&
1344             has_capability('mod/quiz:viewreports', $context) &&
1345             has_capability('moodle/grade:viewhidden', $context)) {
1346         // People who can see reports and hidden grades should be shown everything,
1347         // except during preview when teachers want to see what students see.
1348         $options->attempt = question_display_options::VISIBLE;
1349         $options->correctness = question_display_options::VISIBLE;
1350         $options->marks = question_display_options::MARK_AND_MAX;
1351         $options->feedback = question_display_options::VISIBLE;
1352         $options->numpartscorrect = question_display_options::VISIBLE;
1353         $options->generalfeedback = question_display_options::VISIBLE;
1354         $options->rightanswer = question_display_options::VISIBLE;
1355         $options->overallfeedback = question_display_options::VISIBLE;
1356         $options->history = question_display_options::VISIBLE;
1358     }
1360     return $options;
1363 /**
1364  * Combines the review options from a number of different quiz attempts.
1365  * Returns an array of two ojects, so the suggested way of calling this
1366  * funciton is:
1367  * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...)
1368  *
1369  * @param object $quiz the quiz instance.
1370  * @param array $attempts an array of attempt objects.
1371  * @param $context the roles and permissions context,
1372  *          normally the context for the quiz module instance.
1373  *
1374  * @return array of two options objects, one showing which options are true for
1375  *          at least one of the attempts, the other showing which options are true
1376  *          for all attempts.
1377  */
1378 function quiz_get_combined_reviewoptions($quiz, $attempts) {
1379     $fields = array('feedback', 'generalfeedback', 'rightanswer', 'overallfeedback');
1380     $someoptions = new stdClass();
1381     $alloptions = new stdClass();
1382     foreach ($fields as $field) {
1383         $someoptions->$field = false;
1384         $alloptions->$field = true;
1385     }
1386     $someoptions->marks = question_display_options::HIDDEN;
1387     $alloptions->marks = question_display_options::MARK_AND_MAX;
1389     foreach ($attempts as $attempt) {
1390         $attemptoptions = mod_quiz_display_options::make_from_quiz($quiz,
1391                 quiz_attempt_state($quiz, $attempt));
1392         foreach ($fields as $field) {
1393             $someoptions->$field = $someoptions->$field || $attemptoptions->$field;
1394             $alloptions->$field = $alloptions->$field && $attemptoptions->$field;
1395         }
1396         $someoptions->marks = max($someoptions->marks, $attemptoptions->marks);
1397         $alloptions->marks = min($alloptions->marks, $attemptoptions->marks);
1398     }
1399     return array($someoptions, $alloptions);
1402 /**
1403  * Clean the question layout from various possible anomalies:
1404  * - Remove consecutive ","'s
1405  * - Remove duplicate question id's
1406  * - Remove extra "," from beginning and end
1407  * - Finally, add a ",0" in the end if there is none
1408  *
1409  * @param $string $layout the quiz layout to clean up, usually from $quiz->questions.
1410  * @param bool $removeemptypages If true, remove empty pages from the quiz. False by default.
1411  * @return $string the cleaned-up layout
1412  */
1413 function quiz_clean_layout($layout, $removeemptypages = false) {
1414     // Remove repeated ','s. This can happen when a restore fails to find the right
1415     // id to relink to.
1416     $layout = preg_replace('/,{2,}/', ',', trim($layout, ','));
1418     // Remove duplicate question ids.
1419     $layout = explode(',', $layout);
1420     $cleanerlayout = array();
1421     $seen = array();
1422     foreach ($layout as $item) {
1423         if ($item == 0) {
1424             $cleanerlayout[] = '0';
1425         } else if (!in_array($item, $seen)) {
1426             $cleanerlayout[] = $item;
1427             $seen[] = $item;
1428         }
1429     }
1431     if ($removeemptypages) {
1432         // Avoid duplicate page breaks.
1433         $layout = $cleanerlayout;
1434         $cleanerlayout = array();
1435         $stripfollowingbreaks = true; // Ensure breaks are stripped from the start.
1436         foreach ($layout as $item) {
1437             if ($stripfollowingbreaks && $item == 0) {
1438                 continue;
1439             }
1440             $cleanerlayout[] = $item;
1441             $stripfollowingbreaks = $item == 0;
1442         }
1443     }
1445     // Add a page break at the end if there is none.
1446     if (end($cleanerlayout) !== '0') {
1447         $cleanerlayout[] = '0';
1448     }
1450     return implode(',', $cleanerlayout);
1453 /**
1454  * Get the slot for a question with a particular id.
1455  * @param object $quiz the quiz settings.
1456  * @param int $questionid the of a question in the quiz.
1457  * @return int the corresponding slot. Null if the question is not in the quiz.
1458  */
1459 function quiz_get_slot_for_question($quiz, $questionid) {
1460     $questionids = quiz_questions_in_quiz($quiz->questions);
1461     foreach (explode(',', $questionids) as $key => $id) {
1462         if ($id == $questionid) {
1463             return $key + 1;
1464         }
1465     }
1466     return null;
1469 // Functions for sending notification messages /////////////////////////////////
1471 /**
1472  * Sends a confirmation message to the student confirming that the attempt was processed.
1473  *
1474  * @param object $a lots of useful information that can be used in the message
1475  *      subject and body.
1476  *
1477  * @return int|false as for {@link message_send()}.
1478  */
1479 function quiz_send_confirmation($recipient, $a) {
1481     // Add information about the recipient to $a.
1482     // Don't do idnumber. we want idnumber to be the submitter's idnumber.
1483     $a->username     = fullname($recipient);
1484     $a->userusername = $recipient->username;
1486     // Prepare the message.
1487     $eventdata = new stdClass();
1488     $eventdata->component         = 'mod_quiz';
1489     $eventdata->name              = 'confirmation';
1490     $eventdata->notification      = 1;
1492     $eventdata->userfrom          = get_admin();
1493     $eventdata->userto            = $recipient;
1494     $eventdata->subject           = get_string('emailconfirmsubject', 'quiz', $a);
1495     $eventdata->fullmessage       = get_string('emailconfirmbody', 'quiz', $a);
1496     $eventdata->fullmessageformat = FORMAT_PLAIN;
1497     $eventdata->fullmessagehtml   = '';
1499     $eventdata->smallmessage      = get_string('emailconfirmsmall', 'quiz', $a);
1500     $eventdata->contexturl        = $a->quizurl;
1501     $eventdata->contexturlname    = $a->quizname;
1503     // ... and send it.
1504     return message_send($eventdata);
1507 /**
1508  * Sends notification messages to the interested parties that assign the role capability
1509  *
1510  * @param object $recipient user object of the intended recipient
1511  * @param object $a associative array of replaceable fields for the templates
1512  *
1513  * @return int|false as for {@link message_send()}.
1514  */
1515 function quiz_send_notification($recipient, $submitter, $a) {
1517     // Recipient info for template.
1518     $a->useridnumber = $recipient->idnumber;
1519     $a->username     = fullname($recipient);
1520     $a->userusername = $recipient->username;
1522     // Prepare the message.
1523     $eventdata = new stdClass();
1524     $eventdata->component         = 'mod_quiz';
1525     $eventdata->name              = 'submission';
1526     $eventdata->notification      = 1;
1528     $eventdata->userfrom          = $submitter;
1529     $eventdata->userto            = $recipient;
1530     $eventdata->subject           = get_string('emailnotifysubject', 'quiz', $a);
1531     $eventdata->fullmessage       = get_string('emailnotifybody', 'quiz', $a);
1532     $eventdata->fullmessageformat = FORMAT_PLAIN;
1533     $eventdata->fullmessagehtml   = '';
1535     $eventdata->smallmessage      = get_string('emailnotifysmall', 'quiz', $a);
1536     $eventdata->contexturl        = $a->quizreviewurl;
1537     $eventdata->contexturlname    = $a->quizname;
1539     // ... and send it.
1540     return message_send($eventdata);
1543 /**
1544  * Send all the requried messages when a quiz attempt is submitted.
1545  *
1546  * @param object $course the course
1547  * @param object $quiz the quiz
1548  * @param object $attempt this attempt just finished
1549  * @param object $context the quiz context
1550  * @param object $cm the coursemodule for this quiz
1551  *
1552  * @return bool true if all necessary messages were sent successfully, else false.
1553  */
1554 function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm) {
1555     global $CFG, $DB;
1557     // Do nothing if required objects not present.
1558     if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1559         throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.');
1560     }
1562     $submitter = $DB->get_record('user', array('id' => $attempt->userid), '*', MUST_EXIST);
1564     // Check for confirmation required.
1565     $sendconfirm = false;
1566     $notifyexcludeusers = '';
1567     if (has_capability('mod/quiz:emailconfirmsubmission', $context, $submitter, false)) {
1568         $notifyexcludeusers = $submitter->id;
1569         $sendconfirm = true;
1570     }
1572     // Check for notifications required.
1573     $notifyfields = 'u.id, u.username, u.firstname, u.lastname, u.idnumber, u.email, u.emailstop, ' .
1574             'u.lang, u.timezone, u.mailformat, u.maildisplay';
1575     $groups = groups_get_all_groups($course->id, $submitter->id);
1576     if (is_array($groups) && count($groups) > 0) {
1577         $groups = array_keys($groups);
1578     } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) {
1579         // If the user is not in a group, and the quiz is set to group mode,
1580         // then set $groups to a non-existant id so that only users with
1581         // 'moodle/site:accessallgroups' get notified.
1582         $groups = -1;
1583     } else {
1584         $groups = '';
1585     }
1586     $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission',
1587             $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true);
1589     if (empty($userstonotify) && !$sendconfirm) {
1590         return true; // Nothing to do.
1591     }
1593     $a = new stdClass();
1594     // Course info.
1595     $a->coursename      = $course->fullname;
1596     $a->courseshortname = $course->shortname;
1597     // Quiz info.
1598     $a->quizname        = $quiz->name;
1599     $a->quizreporturl   = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id;
1600     $a->quizreportlink  = '<a href="' . $a->quizreporturl . '">' .
1601             format_string($quiz->name) . ' report</a>';
1602     $a->quizurl         = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id;
1603     $a->quizlink        = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>';
1604     // Attempt info.
1605     $a->submissiontime  = userdate($attempt->timefinish);
1606     $a->timetaken       = format_time($attempt->timefinish - $attempt->timestart);
1607     $a->quizreviewurl   = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id;
1608     $a->quizreviewlink  = '<a href="' . $a->quizreviewurl . '">' .
1609             format_string($quiz->name) . ' review</a>';
1610     // Student who sat the quiz info.
1611     $a->studentidnumber = $submitter->idnumber;
1612     $a->studentname     = fullname($submitter);
1613     $a->studentusername = $submitter->username;
1615     $allok = true;
1617     // Send notifications if required.
1618     if (!empty($userstonotify)) {
1619         foreach ($userstonotify as $recipient) {
1620             $allok = $allok && quiz_send_notification($recipient, $submitter, $a);
1621         }
1622     }
1624     // Send confirmation if required. We send the student confirmation last, so
1625     // that if message sending is being intermittently buggy, which means we send
1626     // some but not all messages, and then try again later, then teachers may get
1627     // duplicate messages, but the student will always get exactly one.
1628     if ($sendconfirm) {
1629         $allok = $allok && quiz_send_confirmation($submitter, $a);
1630     }
1632     return $allok;
1635 /**
1636  * Send the notification message when a quiz attempt becomes overdue.
1637  *
1638  * @param object $course the course
1639  * @param object $quiz the quiz
1640  * @param object $attempt this attempt just finished
1641  * @param object $context the quiz context
1642  * @param object $cm the coursemodule for this quiz
1643  */
1644 function quiz_send_overdue_message($course, $quiz, $attempt, $context, $cm) {
1645     global $CFG, $DB;
1647     // Do nothing if required objects not present.
1648     if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1649         throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.');
1650     }
1652     $submitter = $DB->get_record('user', array('id' => $attempt->userid), '*', MUST_EXIST);
1654     if (!has_capability('mod/quiz:emailwarnoverdue', $context, $submitter, false)) {
1655         return; // Message not required.
1656     }
1658     // Prepare lots of useful information that admins might want to include in
1659     // the email message.
1660     $quizname = format_string($quiz->name);
1662     $deadlines = array();
1663     if ($quiz->timelimit) {
1664         $deadlines[] = $attempt->timestart + $quiz->timelimit;
1665     }
1666     if ($quiz->timeclose) {
1667         $deadlines[] = $quiz->timeclose;
1668     }
1669     $duedate = min($deadlines);
1670     $graceend = $duedate + $quiz->graceperiod;
1672     $a = new stdClass();
1673     // Course info.
1674     $a->coursename         = $course->fullname;
1675     $a->courseshortname    = $course->shortname;
1676     // Quiz info.
1677     $a->quizname           = $quizname;
1678     $a->quizurl            = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id;
1679     $a->quizlink           = '<a href="' . $a->quizurl . '">' . $quizname . '</a>';
1680     // Attempt info.
1681     $a->attemptduedate    = userdate($duedate);
1682     $a->attemptgraceend    = userdate($graceend);
1683     $a->attemptsummaryurl  = $CFG->wwwroot . '/mod/quiz/summary.php?attempt=' . $attempt->id;
1684     $a->attemptsummarylink = '<a href="' . $a->attemptsummaryurl . '">' . $quizname . ' review</a>';
1685     // Student's info.
1686     $a->studentidnumber    = $submitter->idnumber;
1687     $a->studentname        = fullname($submitter);
1688     $a->studentusername    = $submitter->username;
1690     // Prepare the message.
1691     $eventdata = new stdClass();
1692     $eventdata->component         = 'mod_quiz';
1693     $eventdata->name              = 'attempt_overdue';
1694     $eventdata->notification      = 1;
1696     $eventdata->userfrom          = get_admin();
1697     $eventdata->userto            = $submitter;
1698     $eventdata->subject           = get_string('emailoverduesubject', 'quiz', $a);
1699     $eventdata->fullmessage       = get_string('emailoverduebody', 'quiz', $a);
1700     $eventdata->fullmessageformat = FORMAT_PLAIN;
1701     $eventdata->fullmessagehtml   = '';
1703     $eventdata->smallmessage      = get_string('emailoverduesmall', 'quiz', $a);
1704     $eventdata->contexturl        = $a->quizurl;
1705     $eventdata->contexturlname    = $a->quizname;
1707     // Send the message.
1708     return message_send($eventdata);
1711 /**
1712  * Handle the quiz_attempt_submitted event.
1713  *
1714  * This sends the confirmation and notification messages, if required.
1715  *
1716  * @param object $event the event object.
1717  */
1718 function quiz_attempt_submitted_handler($event) {
1719     global $DB;
1721     $course  = $DB->get_record('course', array('id' => $event->courseid));
1722     $quiz    = $DB->get_record('quiz', array('id' => $event->quizid));
1723     $cm      = get_coursemodule_from_id('quiz', $event->cmid, $event->courseid);
1724     $attempt = $DB->get_record('quiz_attempts', array('id' => $event->attemptid));
1726     if (!($course && $quiz && $cm && $attempt)) {
1727         // Something has been deleted since the event was raised. Therefore, the
1728         // event is no longer relevant.
1729         return true;
1730     }
1732     return quiz_send_notification_messages($course, $quiz, $attempt,
1733             context_module::instance($cm->id), $cm);
1736 /**
1737  * Handle the quiz_attempt_overdue event.
1738  *
1739  * For quizzes with applicable settings, this sends a message to the user, reminding
1740  * them that they forgot to submit, and that they have another chance to do so.
1741  *
1742  * @param object $event the event object.
1743  */
1744 function quiz_attempt_overdue_handler($event) {
1745     global $DB;
1747     $course  = $DB->get_record('course', array('id' => $event->courseid));
1748     $quiz    = $DB->get_record('quiz', array('id' => $event->quizid));
1749     $cm      = get_coursemodule_from_id('quiz', $event->cmid, $event->courseid);
1750     $attempt = $DB->get_record('quiz_attempts', array('id' => $event->attemptid));
1752     if (!($course && $quiz && $cm && $attempt)) {
1753         // Something has been deleted since the event was raised. Therefore, the
1754         // event is no longer relevant.
1755         return true;
1756     }
1758     return quiz_send_overdue_message($course, $quiz, $attempt,
1759             context_module::instance($cm->id), $cm);
1762 /**
1763  * Handle groups_member_added event
1764  *
1765  * @param object $event the event object.
1766  */
1767 function quiz_groups_member_added_handler($event) {
1768     quiz_update_open_attempts(array('userid'=>$event->userid, 'groupid'=>$event->groupid));
1771 /**
1772  * Handle groups_member_removed event
1773  *
1774  * @param object $event the event object.
1775  */
1776 function quiz_groups_member_removed_handler($event) {
1777     quiz_update_open_attempts(array('userid'=>$event->userid, 'groupid'=>$event->groupid));
1780 /**
1781  * Handle groups_group_deleted event
1782  *
1783  * @param object $event the event object.
1784  */
1785 function quiz_groups_group_deleted_handler($event) {
1786     global $DB;
1788     // It would be nice if we got the groupid that was deleted.
1789     // Instead, we just update all quizzes with orphaned group overrides
1790     $sql = "SELECT o.id, o.quiz
1791               FROM {quiz_overrides} o
1792               JOIN {quiz} quiz ON quiz.id = o.quiz
1793          LEFT JOIN {groups} grp ON grp.id = o.groupid
1794              WHERE quiz.course = :courseid AND grp.id IS NULL";
1795     $params = array('courseid'=>$event->courseid);
1796     $records = $DB->get_records_sql_menu($sql, $params);
1797     if (!$records) {
1798         return; // Nothing to do.
1799     }
1800     $DB->delete_records_list('quiz_overrides', 'id', array_keys($records));
1801     quiz_update_open_attempts(array('quizid'=>array_unique(array_values($records))));
1804 /**
1805  * Handle groups_members_removed event
1806  *
1807  * @param object $event the event object.
1808  */
1809 function quiz_groups_members_removed_handler($event) {
1810     if ($event->userid == 0) {
1811         quiz_update_open_attempts(array('courseid'=>$event->courseid));
1812     } else {
1813         quiz_update_open_attempts(array('courseid'=>$event->courseid, 'userid'=>$event->userid));
1814     }
1817 /**
1818  * Get the information about the standard quiz JavaScript module.