Merge branch 'wip-MDL-40100-m26' of git://github.com/samhemelryk/moodle
[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');
59 // Functions related to attempts ///////////////////////////////////////////////
61 /**
62  * Creates an object to represent a new attempt at a quiz
63  *
64  * Creates an attempt object to represent an attempt at the quiz by the current
65  * user starting at the current time. The ->id field is not set. The object is
66  * NOT written to the database.
67  *
68  * @param object $quizobj the quiz object to create an attempt for.
69  * @param int $attemptnumber the sequence number for the attempt.
70  * @param object $lastattempt the previous attempt by this user, if any. Only needed
71  *         if $attemptnumber > 1 and $quiz->attemptonlast is true.
72  * @param int $timenow the time the attempt was started at.
73  * @param bool $ispreview whether this new attempt is a preview.
74  *
75  * @return object the newly created attempt object.
76  */
77 function quiz_create_attempt(quiz $quizobj, $attemptnumber, $lastattempt, $timenow, $ispreview = false) {
78     global $USER;
80     $quiz = $quizobj->get_quiz();
81     if ($quiz->sumgrades < 0.000005 && $quiz->grade > 0.000005) {
82         throw new moodle_exception('cannotstartgradesmismatch', 'quiz',
83                 new moodle_url('/mod/quiz/view.php', array('q' => $quiz->id)),
84                     array('grade' => quiz_format_grade($quiz, $quiz->grade)));
85     }
87     if ($attemptnumber == 1 || !$quiz->attemptonlast) {
88         // We are not building on last attempt so create a new attempt.
89         $attempt = new stdClass();
90         $attempt->quiz = $quiz->id;
91         $attempt->userid = $USER->id;
92         $attempt->preview = 0;
93         $attempt->layout = quiz_clean_layout($quiz->questions, true);
94         if ($quiz->shufflequestions) {
95             $attempt->layout = quiz_repaginate($attempt->layout, $quiz->questionsperpage, true);
96         }
97     } else {
98         // Build on last attempt.
99         if (empty($lastattempt)) {
100             print_error('cannotfindprevattempt', 'quiz');
101         }
102         $attempt = $lastattempt;
103     }
105     $attempt->attempt = $attemptnumber;
106     $attempt->timestart = $timenow;
107     $attempt->timefinish = 0;
108     $attempt->timemodified = $timenow;
109     $attempt->state = quiz_attempt::IN_PROGRESS;
111     // If this is a preview, mark it as such.
112     if ($ispreview) {
113         $attempt->preview = 1;
114     }
116     $timeclose = $quizobj->get_access_manager($timenow)->get_end_time($attempt);
117     if ($timeclose === false || $ispreview) {
118         $attempt->timecheckstate = null;
119     } else {
120         $attempt->timecheckstate = $timeclose;
121     }
123     return $attempt;
126 /**
127  * Returns an unfinished attempt (if there is one) for the given
128  * user on the given quiz. This function does not return preview attempts.
129  *
130  * @param int $quizid the id of the quiz.
131  * @param int $userid the id of the user.
132  *
133  * @return mixed the unfinished attempt if there is one, false if not.
134  */
135 function quiz_get_user_attempt_unfinished($quizid, $userid) {
136     $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true);
137     if ($attempts) {
138         return array_shift($attempts);
139     } else {
140         return false;
141     }
144 /**
145  * Delete a quiz attempt.
146  * @param mixed $attempt an integer attempt id or an attempt object
147  *      (row of the quiz_attempts table).
148  * @param object $quiz the quiz object.
149  */
150 function quiz_delete_attempt($attempt, $quiz) {
151     global $DB;
152     if (is_numeric($attempt)) {
153         if (!$attempt = $DB->get_record('quiz_attempts', array('id' => $attempt))) {
154             return;
155         }
156     }
158     if ($attempt->quiz != $quiz->id) {
159         debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " .
160                 "but was passed quiz $quiz->id.");
161         return;
162     }
164     question_engine::delete_questions_usage_by_activity($attempt->uniqueid);
165     $DB->delete_records('quiz_attempts', array('id' => $attempt->id));
167     // Search quiz_attempts for other instances by this user.
168     // If none, then delete record for this quiz, this user from quiz_grades
169     // else recalculate best grade.
170     $userid = $attempt->userid;
171     if (!$DB->record_exists('quiz_attempts', array('userid' => $userid, 'quiz' => $quiz->id))) {
172         $DB->delete_records('quiz_grades', array('userid' => $userid, 'quiz' => $quiz->id));
173     } else {
174         quiz_save_best_grade($quiz, $userid);
175     }
177     quiz_update_grades($quiz, $userid);
180 /**
181  * Delete all the preview attempts at a quiz, or possibly all the attempts belonging
182  * to one user.
183  * @param object $quiz the quiz object.
184  * @param int $userid (optional) if given, only delete the previews belonging to this user.
185  */
186 function quiz_delete_previews($quiz, $userid = null) {
187     global $DB;
188     $conditions = array('quiz' => $quiz->id, 'preview' => 1);
189     if (!empty($userid)) {
190         $conditions['userid'] = $userid;
191     }
192     $previewattempts = $DB->get_records('quiz_attempts', $conditions);
193     foreach ($previewattempts as $attempt) {
194         quiz_delete_attempt($attempt, $quiz);
195     }
198 /**
199  * @param int $quizid The quiz id.
200  * @return bool whether this quiz has any (non-preview) attempts.
201  */
202 function quiz_has_attempts($quizid) {
203     global $DB;
204     return $DB->record_exists('quiz_attempts', array('quiz' => $quizid, 'preview' => 0));
207 // Functions to do with quiz layout and pages //////////////////////////////////
209 /**
210  * Returns a comma separated list of question ids for the quiz
211  *
212  * @param string $layout The string representing the quiz layout. Each page is
213  *      represented as a comma separated list of question ids and 0 indicating
214  *      page breaks. So 5,2,0,3,0 means questions 5 and 2 on page 1 and question
215  *      3 on page 2
216  * @return string comma separated list of question ids, without page breaks.
217  */
218 function quiz_questions_in_quiz($layout) {
219     $questions = str_replace(',0', '', quiz_clean_layout($layout, true));
220     if ($questions === '0') {
221         return '';
222     } else {
223         return $questions;
224     }
227 /**
228  * Returns the number of pages in a quiz layout
229  *
230  * @param string $layout The string representing the quiz layout. Always ends in ,0
231  * @return int The number of pages in the quiz.
232  */
233 function quiz_number_of_pages($layout) {
234     return substr_count(',' . $layout, ',0');
237 /**
238  * Returns the number of questions in the quiz layout
239  *
240  * @param string $layout the string representing the quiz layout.
241  * @return int The number of questions in the quiz.
242  */
243 function quiz_number_of_questions_in_quiz($layout) {
244     $layout = quiz_questions_in_quiz(quiz_clean_layout($layout));
245     $count = substr_count($layout, ',');
246     if ($layout !== '') {
247         $count++;
248     }
249     return $count;
252 /**
253  * Re-paginates the quiz layout
254  *
255  * @param string $layout  The string representing the quiz layout. If there is
256  *      if there is any doubt about the quality of the input data, call
257  *      quiz_clean_layout before you call this function.
258  * @param int $perpage The number of questions per page
259  * @param bool $shuffle Should the questions be reordered randomly?
260  * @return string the new layout string
261  */
262 function quiz_repaginate($layout, $perpage, $shuffle = false) {
263     $questions = quiz_questions_in_quiz($layout);
264     if (!$questions) {
265         return '0';
266     }
268     $questions = explode(',', quiz_questions_in_quiz($layout));
269     if ($shuffle) {
270         shuffle($questions);
271     }
273     $onthispage = 0;
274     $layout = array();
275     foreach ($questions as $question) {
276         if ($perpage and $onthispage >= $perpage) {
277             $layout[] = 0;
278             $onthispage = 0;
279         }
280         $layout[] = $question;
281         $onthispage += 1;
282     }
284     $layout[] = 0;
285     return implode(',', $layout);
288 // Functions to do with quiz grades ////////////////////////////////////////////
290 /**
291  * Creates an array of maximum grades for a quiz
292  *
293  * The grades are extracted from the quiz_question_instances table.
294  * @param object $quiz The quiz settings.
295  * @return array of grades indexed by question id. These are the maximum
296  *      possible grades that students can achieve for each of the questions.
297  */
298 function quiz_get_all_question_grades($quiz) {
299     global $CFG, $DB;
301     $questionlist = quiz_questions_in_quiz($quiz->questions);
302     if (empty($questionlist)) {
303         return array();
304     }
306     $params = array($quiz->id);
307     $wheresql = '';
308     if (!is_null($questionlist)) {
309         list($usql, $question_params) = $DB->get_in_or_equal(explode(',', $questionlist));
310         $wheresql = " AND question $usql ";
311         $params = array_merge($params, $question_params);
312     }
314     $instances = $DB->get_records_sql("SELECT question, grade, id
315                                     FROM {quiz_question_instances}
316                                     WHERE quiz = ? $wheresql", $params);
318     $list = explode(",", $questionlist);
319     $grades = array();
321     foreach ($list as $qid) {
322         if (isset($instances[$qid])) {
323             $grades[$qid] = $instances[$qid]->grade;
324         } else {
325             $grades[$qid] = 1;
326         }
327     }
328     return $grades;
331 /**
332  * Convert the raw grade stored in $attempt into a grade out of the maximum
333  * grade for this quiz.
334  *
335  * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades
336  * @param object $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used.
337  * @param bool|string $format whether to format the results for display
338  *      or 'question' to format a question grade (different number of decimal places.
339  * @return float|string the rescaled grade, or null/the lang string 'notyetgraded'
340  *      if the $grade is null.
341  */
342 function quiz_rescale_grade($rawgrade, $quiz, $format = true) {
343     if (is_null($rawgrade)) {
344         $grade = null;
345     } else if ($quiz->sumgrades >= 0.000005) {
346         $grade = $rawgrade * $quiz->grade / $quiz->sumgrades;
347     } else {
348         $grade = 0;
349     }
350     if ($format === 'question') {
351         $grade = quiz_format_question_grade($quiz, $grade);
352     } else if ($format) {
353         $grade = quiz_format_grade($quiz, $grade);
354     }
355     return $grade;
358 /**
359  * Get the feedback text that should be show to a student who
360  * got this grade on this quiz. The feedback is processed ready for diplay.
361  *
362  * @param float $grade a grade on this quiz.
363  * @param object $quiz the quiz settings.
364  * @param object $context the quiz context.
365  * @return string the comment that corresponds to this grade (empty string if there is not one.
366  */
367 function quiz_feedback_for_grade($grade, $quiz, $context) {
368     global $DB;
370     if (is_null($grade)) {
371         return '';
372     }
374     // With CBM etc, it is possible to get -ve grades, which would then not match
375     // any feedback. Therefore, we replace -ve grades with 0.
376     $grade = max($grade, 0);
378     $feedback = $DB->get_record_select('quiz_feedback',
379             'quizid = ? AND mingrade <= ? AND ? < maxgrade', array($quiz->id, $grade, $grade));
381     if (empty($feedback->feedbacktext)) {
382         return '';
383     }
385     // Clean the text, ready for display.
386     $formatoptions = new stdClass();
387     $formatoptions->noclean = true;
388     $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext, 'pluginfile.php',
389             $context->id, 'mod_quiz', 'feedback', $feedback->id);
390     $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions);
392     return $feedbacktext;
395 /**
396  * @param object $quiz the quiz database row.
397  * @return bool Whether this quiz has any non-blank feedback text.
398  */
399 function quiz_has_feedback($quiz) {
400     global $DB;
401     static $cache = array();
402     if (!array_key_exists($quiz->id, $cache)) {
403         $cache[$quiz->id] = quiz_has_grades($quiz) &&
404                 $DB->record_exists_select('quiz_feedback', "quizid = ? AND " .
405                     $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true),
406                 array($quiz->id));
407     }
408     return $cache[$quiz->id];
411 /**
412  * Update the sumgrades field of the quiz. This needs to be called whenever
413  * the grading structure of the quiz is changed. For example if a question is
414  * added or removed, or a question weight is changed.
415  *
416  * You should call {@link quiz_delete_previews()} before you call this function.
417  *
418  * @param object $quiz a quiz.
419  */
420 function quiz_update_sumgrades($quiz) {
421     global $DB;
423     $sql = 'UPDATE {quiz}
424             SET sumgrades = COALESCE((
425                 SELECT SUM(grade)
426                 FROM {quiz_question_instances}
427                 WHERE quiz = {quiz}.id
428             ), 0)
429             WHERE id = ?';
430     $DB->execute($sql, array($quiz->id));
431     $quiz->sumgrades = $DB->get_field('quiz', 'sumgrades', array('id' => $quiz->id));
433     if ($quiz->sumgrades < 0.000005 && quiz_has_attempts($quiz->id)) {
434         // If the quiz has been attempted, and the sumgrades has been
435         // set to 0, then we must also set the maximum possible grade to 0, or
436         // we will get a divide by zero error.
437         quiz_set_grade(0, $quiz);
438     }
441 /**
442  * Update the sumgrades field of the attempts at a quiz.
443  *
444  * @param object $quiz a quiz.
445  */
446 function quiz_update_all_attempt_sumgrades($quiz) {
447     global $DB;
448     $dm = new question_engine_data_mapper();
449     $timenow = time();
451     $sql = "UPDATE {quiz_attempts}
452             SET
453                 timemodified = :timenow,
454                 sumgrades = (
455                     {$dm->sum_usage_marks_subquery('uniqueid')}
456                 )
457             WHERE quiz = :quizid AND state = :finishedstate";
458     $DB->execute($sql, array('timenow' => $timenow, 'quizid' => $quiz->id,
459             'finishedstate' => quiz_attempt::FINISHED));
462 /**
463  * The quiz grade is the maximum that student's results are marked out of. When it
464  * changes, the corresponding data in quiz_grades and quiz_feedback needs to be
465  * rescaled. After calling this function, you probably need to call
466  * quiz_update_all_attempt_sumgrades, quiz_update_all_final_grades and
467  * quiz_update_grades.
468  *
469  * @param float $newgrade the new maximum grade for the quiz.
470  * @param object $quiz the quiz we are updating. Passed by reference so its
471  *      grade field can be updated too.
472  * @return bool indicating success or failure.
473  */
474 function quiz_set_grade($newgrade, $quiz) {
475     global $DB;
476     // This is potentially expensive, so only do it if necessary.
477     if (abs($quiz->grade - $newgrade) < 1e-7) {
478         // Nothing to do.
479         return true;
480     }
482     $oldgrade = $quiz->grade;
483     $quiz->grade = $newgrade;
485     // Use a transaction, so that on those databases that support it, this is safer.
486     $transaction = $DB->start_delegated_transaction();
488     // Update the quiz table.
489     $DB->set_field('quiz', 'grade', $newgrade, array('id' => $quiz->instance));
491     if ($oldgrade < 1) {
492         // If the old grade was zero, we cannot rescale, we have to recompute.
493         // We also recompute if the old grade was too small to avoid underflow problems.
494         quiz_update_all_final_grades($quiz);
496     } else {
497         // We can rescale the grades efficiently.
498         $timemodified = time();
499         $DB->execute("
500                 UPDATE {quiz_grades}
501                 SET grade = ? * grade, timemodified = ?
502                 WHERE quiz = ?
503         ", array($newgrade/$oldgrade, $timemodified, $quiz->id));
504     }
506     if ($oldgrade > 1e-7) {
507         // Update the quiz_feedback table.
508         $factor = $newgrade/$oldgrade;
509         $DB->execute("
510                 UPDATE {quiz_feedback}
511                 SET mingrade = ? * mingrade, maxgrade = ? * maxgrade
512                 WHERE quizid = ?
513         ", array($factor, $factor, $quiz->id));
514     }
516     // Update grade item and send all grades to gradebook.
517     quiz_grade_item_update($quiz);
518     quiz_update_grades($quiz);
520     $transaction->allow_commit();
521     return true;
524 /**
525  * Save the overall grade for a user at a quiz in the quiz_grades table
526  *
527  * @param object $quiz The quiz for which the best grade is to be calculated and then saved.
528  * @param int $userid The userid to calculate the grade for. Defaults to the current user.
529  * @param array $attempts The attempts of this user. Useful if you are
530  * looping through many users. Attempts can be fetched in one master query to
531  * avoid repeated querying.
532  * @return bool Indicates success or failure.
533  */
534 function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) {
535     global $DB, $OUTPUT, $USER;
537     if (empty($userid)) {
538         $userid = $USER->id;
539     }
541     if (!$attempts) {
542         // Get all the attempts made by the user.
543         $attempts = quiz_get_user_attempts($quiz->id, $userid);
544     }
546     // Calculate the best grade.
547     $bestgrade = quiz_calculate_best_grade($quiz, $attempts);
548     $bestgrade = quiz_rescale_grade($bestgrade, $quiz, false);
550     // Save the best grade in the database.
551     if (is_null($bestgrade)) {
552         $DB->delete_records('quiz_grades', array('quiz' => $quiz->id, 'userid' => $userid));
554     } else if ($grade = $DB->get_record('quiz_grades',
555             array('quiz' => $quiz->id, 'userid' => $userid))) {
556         $grade->grade = $bestgrade;
557         $grade->timemodified = time();
558         $DB->update_record('quiz_grades', $grade);
560     } else {
561         $grade = new stdClass();
562         $grade->quiz = $quiz->id;
563         $grade->userid = $userid;
564         $grade->grade = $bestgrade;
565         $grade->timemodified = time();
566         $DB->insert_record('quiz_grades', $grade);
567     }
569     quiz_update_grades($quiz, $userid);
572 /**
573  * Calculate the overall grade for a quiz given a number of attempts by a particular user.
574  *
575  * @param object $quiz    the quiz settings object.
576  * @param array $attempts an array of all the user's attempts at this quiz in order.
577  * @return float          the overall grade
578  */
579 function quiz_calculate_best_grade($quiz, $attempts) {
581     switch ($quiz->grademethod) {
583         case QUIZ_ATTEMPTFIRST:
584             $firstattempt = reset($attempts);
585             return $firstattempt->sumgrades;
587         case QUIZ_ATTEMPTLAST:
588             $lastattempt = end($attempts);
589             return $lastattempt->sumgrades;
591         case QUIZ_GRADEAVERAGE:
592             $sum = 0;
593             $count = 0;
594             foreach ($attempts as $attempt) {
595                 if (!is_null($attempt->sumgrades)) {
596                     $sum += $attempt->sumgrades;
597                     $count++;
598                 }
599             }
600             if ($count == 0) {
601                 return null;
602             }
603             return $sum / $count;
605         case QUIZ_GRADEHIGHEST:
606         default:
607             $max = null;
608             foreach ($attempts as $attempt) {
609                 if ($attempt->sumgrades > $max) {
610                     $max = $attempt->sumgrades;
611                 }
612             }
613             return $max;
614     }
617 /**
618  * Update the final grade at this quiz for all students.
619  *
620  * This function is equivalent to calling quiz_save_best_grade for all
621  * users, but much more efficient.
622  *
623  * @param object $quiz the quiz settings.
624  */
625 function quiz_update_all_final_grades($quiz) {
626     global $DB;
628     if (!$quiz->sumgrades) {
629         return;
630     }
632     $param = array('iquizid' => $quiz->id, 'istatefinished' => quiz_attempt::FINISHED);
633     $firstlastattemptjoin = "JOIN (
634             SELECT
635                 iquiza.userid,
636                 MIN(attempt) AS firstattempt,
637                 MAX(attempt) AS lastattempt
639             FROM {quiz_attempts} iquiza
641             WHERE
642                 iquiza.state = :istatefinished AND
643                 iquiza.preview = 0 AND
644                 iquiza.quiz = :iquizid
646             GROUP BY iquiza.userid
647         ) first_last_attempts ON first_last_attempts.userid = quiza.userid";
649     switch ($quiz->grademethod) {
650         case QUIZ_ATTEMPTFIRST:
651             // Because of the where clause, there will only be one row, but we
652             // must still use an aggregate function.
653             $select = 'MAX(quiza.sumgrades)';
654             $join = $firstlastattemptjoin;
655             $where = 'quiza.attempt = first_last_attempts.firstattempt AND';
656             break;
658         case QUIZ_ATTEMPTLAST:
659             // Because of the where clause, there will only be one row, but we
660             // must still use an aggregate function.
661             $select = 'MAX(quiza.sumgrades)';
662             $join = $firstlastattemptjoin;
663             $where = 'quiza.attempt = first_last_attempts.lastattempt AND';
664             break;
666         case QUIZ_GRADEAVERAGE:
667             $select = 'AVG(quiza.sumgrades)';
668             $join = '';
669             $where = '';
670             break;
672         default:
673         case QUIZ_GRADEHIGHEST:
674             $select = 'MAX(quiza.sumgrades)';
675             $join = '';
676             $where = '';
677             break;
678     }
680     if ($quiz->sumgrades >= 0.000005) {
681         $finalgrade = $select . ' * ' . ($quiz->grade / $quiz->sumgrades);
682     } else {
683         $finalgrade = '0';
684     }
685     $param['quizid'] = $quiz->id;
686     $param['quizid2'] = $quiz->id;
687     $param['quizid3'] = $quiz->id;
688     $param['quizid4'] = $quiz->id;
689     $param['statefinished'] = quiz_attempt::FINISHED;
690     $param['statefinished2'] = quiz_attempt::FINISHED;
691     $finalgradesubquery = "
692             SELECT quiza.userid, $finalgrade AS newgrade
693             FROM {quiz_attempts} quiza
694             $join
695             WHERE
696                 $where
697                 quiza.state = :statefinished AND
698                 quiza.preview = 0 AND
699                 quiza.quiz = :quizid3
700             GROUP BY quiza.userid";
702     $changedgrades = $DB->get_records_sql("
703             SELECT users.userid, qg.id, qg.grade, newgrades.newgrade
705             FROM (
706                 SELECT userid
707                 FROM {quiz_grades} qg
708                 WHERE quiz = :quizid
709             UNION
710                 SELECT DISTINCT userid
711                 FROM {quiz_attempts} quiza2
712                 WHERE
713                     quiza2.state = :statefinished2 AND
714                     quiza2.preview = 0 AND
715                     quiza2.quiz = :quizid2
716             ) users
718             LEFT JOIN {quiz_grades} qg ON qg.userid = users.userid AND qg.quiz = :quizid4
720             LEFT JOIN (
721                 $finalgradesubquery
722             ) newgrades ON newgrades.userid = users.userid
724             WHERE
725                 ABS(newgrades.newgrade - qg.grade) > 0.000005 OR
726                 ((newgrades.newgrade IS NULL OR qg.grade IS NULL) AND NOT
727                           (newgrades.newgrade IS NULL AND qg.grade IS NULL))",
728                 // The mess on the previous line is detecting where the value is
729                 // NULL in one column, and NOT NULL in the other, but SQL does
730                 // not have an XOR operator, and MS SQL server can't cope with
731                 // (newgrades.newgrade IS NULL) <> (qg.grade IS NULL).
732             $param);
734     $timenow = time();
735     $todelete = array();
736     foreach ($changedgrades as $changedgrade) {
738         if (is_null($changedgrade->newgrade)) {
739             $todelete[] = $changedgrade->userid;
741         } else if (is_null($changedgrade->grade)) {
742             $toinsert = new stdClass();
743             $toinsert->quiz = $quiz->id;
744             $toinsert->userid = $changedgrade->userid;
745             $toinsert->timemodified = $timenow;
746             $toinsert->grade = $changedgrade->newgrade;
747             $DB->insert_record('quiz_grades', $toinsert);
749         } else {
750             $toupdate = new stdClass();
751             $toupdate->id = $changedgrade->id;
752             $toupdate->grade = $changedgrade->newgrade;
753             $toupdate->timemodified = $timenow;
754             $DB->update_record('quiz_grades', $toupdate);
755         }
756     }
758     if (!empty($todelete)) {
759         list($test, $params) = $DB->get_in_or_equal($todelete);
760         $DB->delete_records_select('quiz_grades', 'quiz = ? AND userid ' . $test,
761                 array_merge(array($quiz->id), $params));
762     }
765 /**
766  * Efficiently update check state time on all open attempts
767  *
768  * @param array $conditions optional restrictions on which attempts to update
769  *                    Allowed conditions:
770  *                      courseid => (array|int) attempts in given course(s)
771  *                      userid   => (array|int) attempts for given user(s)
772  *                      quizid   => (array|int) attempts in given quiz(s)
773  *                      groupid  => (array|int) quizzes with some override for given group(s)
774  *
775  */
776 function quiz_update_open_attempts(array $conditions) {
777     global $DB;
779     foreach ($conditions as &$value) {
780         if (!is_array($value)) {
781             $value = array($value);
782         }
783     }
785     $params = array();
786     $wheres = array("quiza.state IN ('inprogress', 'overdue')");
787     $iwheres = array("iquiza.state IN ('inprogress', 'overdue')");
789     if (isset($conditions['courseid'])) {
790         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'cid');
791         $params = array_merge($params, $inparams);
792         $wheres[] = "quiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)";
793         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'icid');
794         $params = array_merge($params, $inparams);
795         $iwheres[] = "iquiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)";
796     }
798     if (isset($conditions['userid'])) {
799         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'uid');
800         $params = array_merge($params, $inparams);
801         $wheres[] = "quiza.userid $incond";
802         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'iuid');
803         $params = array_merge($params, $inparams);
804         $iwheres[] = "iquiza.userid $incond";
805     }
807     if (isset($conditions['quizid'])) {
808         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'qid');
809         $params = array_merge($params, $inparams);
810         $wheres[] = "quiza.quiz $incond";
811         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'iqid');
812         $params = array_merge($params, $inparams);
813         $iwheres[] = "iquiza.quiz $incond";
814     }
816     if (isset($conditions['groupid'])) {
817         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'gid');
818         $params = array_merge($params, $inparams);
819         $wheres[] = "quiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)";
820         list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'igid');
821         $params = array_merge($params, $inparams);
822         $iwheres[] = "iquiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)";
823     }
825     // SQL to compute timeclose and timelimit for each attempt:
826     $quizausersql = quiz_get_attempt_usertime_sql(
827             implode("\n                AND ", $iwheres));
829     // SQL to compute the new timecheckstate
830     $timecheckstatesql = "
831           CASE WHEN quizauser.usertimelimit = 0 AND quizauser.usertimeclose = 0 THEN NULL
832                WHEN quizauser.usertimelimit = 0 THEN quizauser.usertimeclose
833                WHEN quizauser.usertimeclose = 0 THEN quiza.timestart + quizauser.usertimelimit
834                WHEN quiza.timestart + quizauser.usertimelimit < quizauser.usertimeclose THEN quiza.timestart + quizauser.usertimelimit
835                ELSE quizauser.usertimeclose END +
836           CASE WHEN quiza.state = 'overdue' THEN quiz.graceperiod ELSE 0 END";
838     // SQL to select which attempts to process
839     $attemptselect = implode("\n                         AND ", $wheres);
841    /*
842     * Each database handles updates with inner joins differently:
843     *  - mysql does not allow a FROM clause
844     *  - postgres and mssql allow FROM but handle table aliases differently
845     *  - oracle requires a subquery
846     *
847     * Different code for each database.
848     */
850     $dbfamily = $DB->get_dbfamily();
851     if ($dbfamily == 'mysql') {
852         $updatesql = "UPDATE {quiz_attempts} quiza
853                         JOIN {quiz} quiz ON quiz.id = quiza.quiz
854                         JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id
855                          SET quiza.timecheckstate = $timecheckstatesql
856                        WHERE $attemptselect";
857     } else if ($dbfamily == 'postgres') {
858         $updatesql = "UPDATE {quiz_attempts} quiza
859                          SET timecheckstate = $timecheckstatesql
860                         FROM {quiz} quiz, ( $quizausersql ) quizauser
861                        WHERE quiz.id = quiza.quiz
862                          AND quizauser.id = quiza.id
863                          AND $attemptselect";
864     } else if ($dbfamily == 'mssql') {
865         $updatesql = "UPDATE quiza
866                          SET timecheckstate = $timecheckstatesql
867                         FROM {quiz_attempts} quiza
868                         JOIN {quiz} quiz ON quiz.id = quiza.quiz
869                         JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id
870                        WHERE $attemptselect";
871     } else {
872         // oracle, sqlite and others
873         $updatesql = "UPDATE {quiz_attempts} quiza
874                          SET timecheckstate = (
875                            SELECT $timecheckstatesql
876                              FROM {quiz} quiz, ( $quizausersql ) quizauser
877                             WHERE quiz.id = quiza.quiz
878                               AND quizauser.id = quiza.id
879                          )
880                          WHERE $attemptselect";
881     }
883     $DB->execute($updatesql, $params);
886 /**
887  * Returns SQL to compute timeclose and timelimit for every attempt, taking into account user and group overrides.
888  *
889  * @param string $redundantwhereclauses extra where clauses to add to the subquery
890  *      for performance. These can use the table alias iquiza for the quiz attempts table.
891  * @return string SQL select with columns attempt.id, usertimeclose, usertimelimit.
892  */
893 function quiz_get_attempt_usertime_sql($redundantwhereclauses = '') {
894     if ($redundantwhereclauses) {
895         $redundantwhereclauses = 'WHERE ' . $redundantwhereclauses;
896     }
897     // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede
898     // any other group override
899     $quizausersql = "
900           SELECT iquiza.id,
901            COALESCE(MAX(quo.timeclose), MAX(qgo1.timeclose), MAX(qgo2.timeclose), iquiz.timeclose) AS usertimeclose,
902            COALESCE(MAX(quo.timelimit), MAX(qgo3.timelimit), MAX(qgo4.timelimit), iquiz.timelimit) AS usertimelimit
904            FROM {quiz_attempts} iquiza
905            JOIN {quiz} iquiz ON iquiz.id = iquiza.quiz
906       LEFT JOIN {quiz_overrides} quo ON quo.quiz = iquiza.quiz AND quo.userid = iquiza.userid
907       LEFT JOIN {groups_members} gm ON gm.userid = iquiza.userid
908       LEFT JOIN {quiz_overrides} qgo1 ON qgo1.quiz = iquiza.quiz AND qgo1.groupid = gm.groupid AND qgo1.timeclose = 0
909       LEFT JOIN {quiz_overrides} qgo2 ON qgo2.quiz = iquiza.quiz AND qgo2.groupid = gm.groupid AND qgo2.timeclose > 0
910       LEFT JOIN {quiz_overrides} qgo3 ON qgo3.quiz = iquiza.quiz AND qgo3.groupid = gm.groupid AND qgo3.timelimit = 0
911       LEFT JOIN {quiz_overrides} qgo4 ON qgo4.quiz = iquiza.quiz AND qgo4.groupid = gm.groupid AND qgo4.timelimit > 0
912           $redundantwhereclauses
913        GROUP BY iquiza.id, iquiz.id, iquiz.timeclose, iquiz.timelimit";
914     return $quizausersql;
917 /**
918  * Return the attempt with the best grade for a quiz
919  *
920  * Which attempt is the best depends on $quiz->grademethod. If the grade
921  * method is GRADEAVERAGE then this function simply returns the last attempt.
922  * @return object         The attempt with the best grade
923  * @param object $quiz    The quiz for which the best grade is to be calculated
924  * @param array $attempts An array of all the attempts of the user at the quiz
925  */
926 function quiz_calculate_best_attempt($quiz, $attempts) {
928     switch ($quiz->grademethod) {
930         case QUIZ_ATTEMPTFIRST:
931             foreach ($attempts as $attempt) {
932                 return $attempt;
933             }
934             break;
936         case QUIZ_GRADEAVERAGE: // We need to do something with it.
937         case QUIZ_ATTEMPTLAST:
938             foreach ($attempts as $attempt) {
939                 $final = $attempt;
940             }
941             return $final;
943         default:
944         case QUIZ_GRADEHIGHEST:
945             $max = -1;
946             foreach ($attempts as $attempt) {
947                 if ($attempt->sumgrades > $max) {
948                     $max = $attempt->sumgrades;
949                     $maxattempt = $attempt;
950                 }
951             }
952             return $maxattempt;
953     }
956 /**
957  * @return array int => lang string the options for calculating the quiz grade
958  *      from the individual attempt grades.
959  */
960 function quiz_get_grading_options() {
961     return array(
962         QUIZ_GRADEHIGHEST => get_string('gradehighest', 'quiz'),
963         QUIZ_GRADEAVERAGE => get_string('gradeaverage', 'quiz'),
964         QUIZ_ATTEMPTFIRST => get_string('attemptfirst', 'quiz'),
965         QUIZ_ATTEMPTLAST  => get_string('attemptlast', 'quiz')
966     );
969 /**
970  * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE,
971  *      QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST.
972  * @return the lang string for that option.
973  */
974 function quiz_get_grading_option_name($option) {
975     $strings = quiz_get_grading_options();
976     return $strings[$option];
979 /**
980  * @return array string => lang string the options for handling overdue quiz
981  *      attempts.
982  */
983 function quiz_get_overdue_handling_options() {
984     return array(
985         'autosubmit'  => get_string('overduehandlingautosubmit', 'quiz'),
986         'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'),
987         'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'),
988     );
991 /**
992  * @param string $state one of the state constants like IN_PROGRESS.
993  * @return string the human-readable state name.
994  */
995 function quiz_attempt_state_name($state) {
996     switch ($state) {
997         case quiz_attempt::IN_PROGRESS:
998             return get_string('stateinprogress', 'quiz');
999         case quiz_attempt::OVERDUE:
1000             return get_string('stateoverdue', 'quiz');
1001         case quiz_attempt::FINISHED:
1002             return get_string('statefinished', 'quiz');
1003         case quiz_attempt::ABANDONED:
1004             return get_string('stateabandoned', 'quiz');
1005         default:
1006             throw new coding_exception('Unknown quiz attempt state.');
1007     }
1010 // Other quiz functions ////////////////////////////////////////////////////////
1012 /**
1013  * @param object $quiz the quiz.
1014  * @param int $cmid the course_module object for this quiz.
1015  * @param object $question the question.
1016  * @param string $returnurl url to return to after action is done.
1017  * @return string html for a number of icons linked to action pages for a
1018  * question - preview and edit / view icons depending on user capabilities.
1019  */
1020 function quiz_question_action_icons($quiz, $cmid, $question, $returnurl) {
1021     $html = quiz_question_preview_button($quiz, $question) . ' ' .
1022             quiz_question_edit_button($cmid, $question, $returnurl);
1023     return $html;
1026 /**
1027  * @param int $cmid the course_module.id for this quiz.
1028  * @param object $question the question.
1029  * @param string $returnurl url to return to after action is done.
1030  * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon.
1031  * @return the HTML for an edit icon, view icon, or nothing for a question
1032  *      (depending on permissions).
1033  */
1034 function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') {
1035     global $CFG, $OUTPUT;
1037     // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page.
1038     static $stredit = null;
1039     static $strview = null;
1040     if ($stredit === null) {
1041         $stredit = get_string('edit');
1042         $strview = get_string('view');
1043     }
1045     // What sort of icon should we show?
1046     $action = '';
1047     if (!empty($question->id) &&
1048             (question_has_capability_on($question, 'edit', $question->category) ||
1049                     question_has_capability_on($question, 'move', $question->category))) {
1050         $action = $stredit;
1051         $icon = '/t/edit';
1052     } else if (!empty($question->id) &&
1053             question_has_capability_on($question, 'view', $question->category)) {
1054         $action = $strview;
1055         $icon = '/i/info';
1056     }
1058     // Build the icon.
1059     if ($action) {
1060         if ($returnurl instanceof moodle_url) {
1061             $returnurl = $returnurl->out_as_local_url(false);
1062         }
1063         $questionparams = array('returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id);
1064         $questionurl = new moodle_url("$CFG->wwwroot/question/question.php", $questionparams);
1065         return '<a title="' . $action . '" href="' . $questionurl->out() . '" class="questioneditbutton"><img src="' .
1066                 $OUTPUT->pix_url($icon) . '" alt="' . $action . '" />' . $contentaftericon .
1067                 '</a>';
1068     } else if ($contentaftericon) {
1069         return '<span class="questioneditbutton">' . $contentaftericon . '</span>';
1070     } else {
1071         return '';
1072     }
1075 /**
1076  * @param object $quiz the quiz settings
1077  * @param object $question the question
1078  * @return moodle_url to preview this question with the options from this quiz.
1079  */
1080 function quiz_question_preview_url($quiz, $question) {
1081     // Get the appropriate display options.
1082     $displayoptions = mod_quiz_display_options::make_from_quiz($quiz,
1083             mod_quiz_display_options::DURING);
1085     $maxmark = null;
1086     if (isset($question->maxmark)) {
1087         $maxmark = $question->maxmark;
1088     }
1090     // Work out the correcte preview URL.
1091     return question_preview_url($question->id, $quiz->preferredbehaviour,
1092             $maxmark, $displayoptions);
1095 /**
1096  * @param object $quiz the quiz settings
1097  * @param object $question the question
1098  * @param bool $label if true, show the preview question label after the icon
1099  * @return the HTML for a preview question icon.
1100  */
1101 function quiz_question_preview_button($quiz, $question, $label = false) {
1102     global $CFG, $OUTPUT;
1103     if (!question_has_capability_on($question, 'use', $question->category)) {
1104         return '';
1105     }
1107     $url = quiz_question_preview_url($quiz, $question);
1109     // Do we want a label?
1110     $strpreviewlabel = '';
1111     if ($label) {
1112         $strpreviewlabel = get_string('preview', 'quiz');
1113     }
1115     // Build the icon.
1116     $strpreviewquestion = get_string('previewquestion', 'quiz');
1117     $image = $OUTPUT->pix_icon('t/preview', $strpreviewquestion);
1119     $action = new popup_action('click', $url, 'questionpreview',
1120             question_preview_popup_params());
1122     return $OUTPUT->action_link($url, $image, $action, array('title' => $strpreviewquestion));
1125 /**
1126  * @param object $attempt the attempt.
1127  * @param object $context the quiz context.
1128  * @return int whether flags should be shown/editable to the current user for this attempt.
1129  */
1130 function quiz_get_flag_option($attempt, $context) {
1131     global $USER;
1132     if (!has_capability('moodle/question:flag', $context)) {
1133         return question_display_options::HIDDEN;
1134     } else if ($attempt->userid == $USER->id) {
1135         return question_display_options::EDITABLE;
1136     } else {
1137         return question_display_options::VISIBLE;
1138     }
1141 /**
1142  * Work out what state this quiz attempt is in - in the sense used by
1143  * quiz_get_review_options, not in the sense of $attempt->state.
1144  * @param object $quiz the quiz settings
1145  * @param object $attempt the quiz_attempt database row.
1146  * @return int one of the mod_quiz_display_options::DURING,
1147  *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
1148  */
1149 function quiz_attempt_state($quiz, $attempt) {
1150     if ($attempt->state == quiz_attempt::IN_PROGRESS) {
1151         return mod_quiz_display_options::DURING;
1152     } else if (time() < $attempt->timefinish + 120) {
1153         return mod_quiz_display_options::IMMEDIATELY_AFTER;
1154     } else if (!$quiz->timeclose || time() < $quiz->timeclose) {
1155         return mod_quiz_display_options::LATER_WHILE_OPEN;
1156     } else {
1157         return mod_quiz_display_options::AFTER_CLOSE;
1158     }
1161 /**
1162  * The the appropraite mod_quiz_display_options object for this attempt at this
1163  * quiz right now.
1164  *
1165  * @param object $quiz the quiz instance.
1166  * @param object $attempt the attempt in question.
1167  * @param $context the quiz context.
1168  *
1169  * @return mod_quiz_display_options
1170  */
1171 function quiz_get_review_options($quiz, $attempt, $context) {
1172     $options = mod_quiz_display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt));
1174     $options->readonly = true;
1175     $options->flags = quiz_get_flag_option($attempt, $context);
1176     if (!empty($attempt->id)) {
1177         $options->questionreviewlink = new moodle_url('/mod/quiz/reviewquestion.php',
1178                 array('attempt' => $attempt->id));
1179     }
1181     // Show a link to the comment box only for closed attempts.
1182     if (!empty($attempt->id) && $attempt->state == quiz_attempt::FINISHED && !$attempt->preview &&
1183             !is_null($context) && has_capability('mod/quiz:grade', $context)) {
1184         $options->manualcomment = question_display_options::VISIBLE;
1185         $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php',
1186                 array('attempt' => $attempt->id));
1187     }
1189     if (!is_null($context) && !$attempt->preview &&
1190             has_capability('mod/quiz:viewreports', $context) &&
1191             has_capability('moodle/grade:viewhidden', $context)) {
1192         // People who can see reports and hidden grades should be shown everything,
1193         // except during preview when teachers want to see what students see.
1194         $options->attempt = question_display_options::VISIBLE;
1195         $options->correctness = question_display_options::VISIBLE;
1196         $options->marks = question_display_options::MARK_AND_MAX;
1197         $options->feedback = question_display_options::VISIBLE;
1198         $options->numpartscorrect = question_display_options::VISIBLE;
1199         $options->generalfeedback = question_display_options::VISIBLE;
1200         $options->rightanswer = question_display_options::VISIBLE;
1201         $options->overallfeedback = question_display_options::VISIBLE;
1202         $options->history = question_display_options::VISIBLE;
1204     }
1206     return $options;
1209 /**
1210  * Combines the review options from a number of different quiz attempts.
1211  * Returns an array of two ojects, so the suggested way of calling this
1212  * funciton is:
1213  * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...)
1214  *
1215  * @param object $quiz the quiz instance.
1216  * @param array $attempts an array of attempt objects.
1217  * @param $context the roles and permissions context,
1218  *          normally the context for the quiz module instance.
1219  *
1220  * @return array of two options objects, one showing which options are true for
1221  *          at least one of the attempts, the other showing which options are true
1222  *          for all attempts.
1223  */
1224 function quiz_get_combined_reviewoptions($quiz, $attempts) {
1225     $fields = array('feedback', 'generalfeedback', 'rightanswer', 'overallfeedback');
1226     $someoptions = new stdClass();
1227     $alloptions = new stdClass();
1228     foreach ($fields as $field) {
1229         $someoptions->$field = false;
1230         $alloptions->$field = true;
1231     }
1232     $someoptions->marks = question_display_options::HIDDEN;
1233     $alloptions->marks = question_display_options::MARK_AND_MAX;
1235     foreach ($attempts as $attempt) {
1236         $attemptoptions = mod_quiz_display_options::make_from_quiz($quiz,
1237                 quiz_attempt_state($quiz, $attempt));
1238         foreach ($fields as $field) {
1239             $someoptions->$field = $someoptions->$field || $attemptoptions->$field;
1240             $alloptions->$field = $alloptions->$field && $attemptoptions->$field;
1241         }
1242         $someoptions->marks = max($someoptions->marks, $attemptoptions->marks);
1243         $alloptions->marks = min($alloptions->marks, $attemptoptions->marks);
1244     }
1245     return array($someoptions, $alloptions);
1248 /**
1249  * Clean the question layout from various possible anomalies:
1250  * - Remove consecutive ","'s
1251  * - Remove duplicate question id's
1252  * - Remove extra "," from beginning and end
1253  * - Finally, add a ",0" in the end if there is none
1254  *
1255  * @param $string $layout the quiz layout to clean up, usually from $quiz->questions.
1256  * @param bool $removeemptypages If true, remove empty pages from the quiz. False by default.
1257  * @return $string the cleaned-up layout
1258  */
1259 function quiz_clean_layout($layout, $removeemptypages = false) {
1260     // Remove repeated ','s. This can happen when a restore fails to find the right
1261     // id to relink to.
1262     $layout = preg_replace('/,{2,}/', ',', trim($layout, ','));
1264     // Remove duplicate question ids.
1265     $layout = explode(',', $layout);
1266     $cleanerlayout = array();
1267     $seen = array();
1268     foreach ($layout as $item) {
1269         if ($item == 0) {
1270             $cleanerlayout[] = '0';
1271         } else if (!in_array($item, $seen)) {
1272             $cleanerlayout[] = $item;
1273             $seen[] = $item;
1274         }
1275     }
1277     if ($removeemptypages) {
1278         // Avoid duplicate page breaks.
1279         $layout = $cleanerlayout;
1280         $cleanerlayout = array();
1281         $stripfollowingbreaks = true; // Ensure breaks are stripped from the start.
1282         foreach ($layout as $item) {
1283             if ($stripfollowingbreaks && $item == 0) {
1284                 continue;
1285             }
1286             $cleanerlayout[] = $item;
1287             $stripfollowingbreaks = $item == 0;
1288         }
1289     }
1291     // Add a page break at the end if there is none.
1292     if (end($cleanerlayout) !== '0') {
1293         $cleanerlayout[] = '0';
1294     }
1296     return implode(',', $cleanerlayout);
1299 /**
1300  * Get the slot for a question with a particular id.
1301  * @param object $quiz the quiz settings.
1302  * @param int $questionid the of a question in the quiz.
1303  * @return int the corresponding slot. Null if the question is not in the quiz.
1304  */
1305 function quiz_get_slot_for_question($quiz, $questionid) {
1306     $questionids = quiz_questions_in_quiz($quiz->questions);
1307     foreach (explode(',', $questionids) as $key => $id) {
1308         if ($id == $questionid) {
1309             return $key + 1;
1310         }
1311     }
1312     return null;
1315 // Functions for sending notification messages /////////////////////////////////
1317 /**
1318  * Sends a confirmation message to the student confirming that the attempt was processed.
1319  *
1320  * @param object $a lots of useful information that can be used in the message
1321  *      subject and body.
1322  *
1323  * @return int|false as for {@link message_send()}.
1324  */
1325 function quiz_send_confirmation($recipient, $a) {
1327     // Add information about the recipient to $a.
1328     // Don't do idnumber. we want idnumber to be the submitter's idnumber.
1329     $a->username     = fullname($recipient);
1330     $a->userusername = $recipient->username;
1332     // Prepare the message.
1333     $eventdata = new stdClass();
1334     $eventdata->component         = 'mod_quiz';
1335     $eventdata->name              = 'confirmation';
1336     $eventdata->notification      = 1;
1338     $eventdata->userfrom          = get_admin();
1339     $eventdata->userto            = $recipient;
1340     $eventdata->subject           = get_string('emailconfirmsubject', 'quiz', $a);
1341     $eventdata->fullmessage       = get_string('emailconfirmbody', 'quiz', $a);
1342     $eventdata->fullmessageformat = FORMAT_PLAIN;
1343     $eventdata->fullmessagehtml   = '';
1345     $eventdata->smallmessage      = get_string('emailconfirmsmall', 'quiz', $a);
1346     $eventdata->contexturl        = $a->quizurl;
1347     $eventdata->contexturlname    = $a->quizname;
1349     // ... and send it.
1350     return message_send($eventdata);
1353 /**
1354  * Sends notification messages to the interested parties that assign the role capability
1355  *
1356  * @param object $recipient user object of the intended recipient
1357  * @param object $a associative array of replaceable fields for the templates
1358  *
1359  * @return int|false as for {@link message_send()}.
1360  */
1361 function quiz_send_notification($recipient, $submitter, $a) {
1363     // Recipient info for template.
1364     $a->useridnumber = $recipient->idnumber;
1365     $a->username     = fullname($recipient);
1366     $a->userusername = $recipient->username;
1368     // Prepare the message.
1369     $eventdata = new stdClass();
1370     $eventdata->component         = 'mod_quiz';
1371     $eventdata->name              = 'submission';
1372     $eventdata->notification      = 1;
1374     $eventdata->userfrom          = $submitter;
1375     $eventdata->userto            = $recipient;
1376     $eventdata->subject           = get_string('emailnotifysubject', 'quiz', $a);
1377     $eventdata->fullmessage       = get_string('emailnotifybody', 'quiz', $a);
1378     $eventdata->fullmessageformat = FORMAT_PLAIN;
1379     $eventdata->fullmessagehtml   = '';
1381     $eventdata->smallmessage      = get_string('emailnotifysmall', 'quiz', $a);
1382     $eventdata->contexturl        = $a->quizreviewurl;
1383     $eventdata->contexturlname    = $a->quizname;
1385     // ... and send it.
1386     return message_send($eventdata);
1389 /**
1390  * Send all the requried messages when a quiz attempt is submitted.
1391  *
1392  * @param object $course the course
1393  * @param object $quiz the quiz
1394  * @param object $attempt this attempt just finished
1395  * @param object $context the quiz context
1396  * @param object $cm the coursemodule for this quiz
1397  *
1398  * @return bool true if all necessary messages were sent successfully, else false.
1399  */
1400 function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm) {
1401     global $CFG, $DB;
1403     // Do nothing if required objects not present.
1404     if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1405         throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.');
1406     }
1408     $submitter = $DB->get_record('user', array('id' => $attempt->userid), '*', MUST_EXIST);
1410     // Check for confirmation required.
1411     $sendconfirm = false;
1412     $notifyexcludeusers = '';
1413     if (has_capability('mod/quiz:emailconfirmsubmission', $context, $submitter, false)) {
1414         $notifyexcludeusers = $submitter->id;
1415         $sendconfirm = true;
1416     }
1418     // Check for notifications required.
1419     $notifyfields = 'u.id, u.username, u.firstname, u.lastname, u.idnumber, u.email, u.emailstop, ' .
1420             'u.lang, u.timezone, u.mailformat, u.maildisplay';
1421     $groups = groups_get_all_groups($course->id, $submitter->id);
1422     if (is_array($groups) && count($groups) > 0) {
1423         $groups = array_keys($groups);
1424     } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) {
1425         // If the user is not in a group, and the quiz is set to group mode,
1426         // then set $groups to a non-existant id so that only users with
1427         // 'moodle/site:accessallgroups' get notified.
1428         $groups = -1;
1429     } else {
1430         $groups = '';
1431     }
1432     $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission',
1433             $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true);
1435     if (empty($userstonotify) && !$sendconfirm) {
1436         return true; // Nothing to do.
1437     }
1439     $a = new stdClass();
1440     // Course info.
1441     $a->coursename      = $course->fullname;
1442     $a->courseshortname = $course->shortname;
1443     // Quiz info.
1444     $a->quizname        = $quiz->name;
1445     $a->quizreporturl   = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id;
1446     $a->quizreportlink  = '<a href="' . $a->quizreporturl . '">' .
1447             format_string($quiz->name) . ' report</a>';
1448     $a->quizurl         = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id;
1449     $a->quizlink        = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>';
1450     // Attempt info.
1451     $a->submissiontime  = userdate($attempt->timefinish);
1452     $a->timetaken       = format_time($attempt->timefinish - $attempt->timestart);
1453     $a->quizreviewurl   = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id;
1454     $a->quizreviewlink  = '<a href="' . $a->quizreviewurl . '">' .
1455             format_string($quiz->name) . ' review</a>';
1456     // Student who sat the quiz info.
1457     $a->studentidnumber = $submitter->idnumber;
1458     $a->studentname     = fullname($submitter);
1459     $a->studentusername = $submitter->username;
1461     $allok = true;
1463     // Send notifications if required.
1464     if (!empty($userstonotify)) {
1465         foreach ($userstonotify as $recipient) {
1466             $allok = $allok && quiz_send_notification($recipient, $submitter, $a);
1467         }
1468     }
1470     // Send confirmation if required. We send the student confirmation last, so
1471     // that if message sending is being intermittently buggy, which means we send
1472     // some but not all messages, and then try again later, then teachers may get
1473     // duplicate messages, but the student will always get exactly one.
1474     if ($sendconfirm) {
1475         $allok = $allok && quiz_send_confirmation($submitter, $a);
1476     }
1478     return $allok;
1481 /**
1482  * Send the notification message when a quiz attempt becomes overdue.
1483  *
1484  * @param object $course the course
1485  * @param object $quiz the quiz
1486  * @param object $attempt this attempt just finished
1487  * @param object $context the quiz context
1488  * @param object $cm the coursemodule for this quiz
1489  */
1490 function quiz_send_overdue_message($course, $quiz, $attempt, $context, $cm) {
1491     global $CFG, $DB;
1493     // Do nothing if required objects not present.
1494     if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1495         throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.');
1496     }
1498     $submitter = $DB->get_record('user', array('id' => $attempt->userid), '*', MUST_EXIST);
1500     if (!has_capability('mod/quiz:emailwarnoverdue', $context, $submitter, false)) {
1501         return; // Message not required.
1502     }
1504     // Prepare lots of useful information that admins might want to include in
1505     // the email message.
1506     $quizname = format_string($quiz->name);
1508     $deadlines = array();
1509     if ($quiz->timelimit) {
1510         $deadlines[] = $attempt->timestart + $quiz->timelimit;
1511     }
1512     if ($quiz->timeclose) {
1513         $deadlines[] = $quiz->timeclose;
1514     }
1515     $duedate = min($deadlines);
1516     $graceend = $duedate + $quiz->graceperiod;
1518     $a = new stdClass();
1519     // Course info.
1520     $a->coursename         = $course->fullname;
1521     $a->courseshortname    = $course->shortname;
1522     // Quiz info.
1523     $a->quizname           = $quizname;
1524     $a->quizurl            = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id;
1525     $a->quizlink           = '<a href="' . $a->quizurl . '">' . $quizname . '</a>';
1526     // Attempt info.
1527     $a->attemptduedate    = userdate($duedate);
1528     $a->attemptgraceend    = userdate($graceend);
1529     $a->attemptsummaryurl  = $CFG->wwwroot . '/mod/quiz/summary.php?attempt=' . $attempt->id;
1530     $a->attemptsummarylink = '<a href="' . $a->attemptsummaryurl . '">' . $quizname . ' review</a>';
1531     // Student's info.
1532     $a->studentidnumber    = $submitter->idnumber;
1533     $a->studentname        = fullname($submitter);
1534     $a->studentusername    = $submitter->username;
1536     // Prepare the message.
1537     $eventdata = new stdClass();
1538     $eventdata->component         = 'mod_quiz';
1539     $eventdata->name              = 'attempt_overdue';
1540     $eventdata->notification      = 1;
1542     $eventdata->userfrom          = get_admin();
1543     $eventdata->userto            = $submitter;
1544     $eventdata->subject           = get_string('emailoverduesubject', 'quiz', $a);
1545     $eventdata->fullmessage       = get_string('emailoverduebody', 'quiz', $a);
1546     $eventdata->fullmessageformat = FORMAT_PLAIN;
1547     $eventdata->fullmessagehtml   = '';
1549     $eventdata->smallmessage      = get_string('emailoverduesmall', 'quiz', $a);
1550     $eventdata->contexturl        = $a->quizurl;
1551     $eventdata->contexturlname    = $a->quizname;
1553     // Send the message.
1554     return message_send($eventdata);
1557 /**
1558  * Handle the quiz_attempt_submitted event.
1559  *
1560  * This sends the confirmation and notification messages, if required.
1561  *
1562  * @param object $event the event object.
1563  */
1564 function quiz_attempt_submitted_handler($event) {
1565     global $DB;
1567     $course  = $DB->get_record('course', array('id' => $event->courseid));
1568     $quiz    = $DB->get_record('quiz', array('id' => $event->quizid));
1569     $cm      = get_coursemodule_from_id('quiz', $event->cmid, $event->courseid);
1570     $attempt = $DB->get_record('quiz_attempts', array('id' => $event->attemptid));
1572     if (!($course && $quiz && $cm && $attempt)) {
1573         // Something has been deleted since the event was raised. Therefore, the
1574         // event is no longer relevant.
1575         return true;
1576     }
1578     return quiz_send_notification_messages($course, $quiz, $attempt,
1579             context_module::instance($cm->id), $cm);
1582 /**
1583  * Handle the quiz_attempt_overdue event.
1584  *
1585  * For quizzes with applicable settings, this sends a message to the user, reminding
1586  * them that they forgot to submit, and that they have another chance to do so.
1587  *
1588  * @param object $event the event object.
1589  */
1590 function quiz_attempt_overdue_handler($event) {
1591     global $DB;
1593     $course  = $DB->get_record('course', array('id' => $event->courseid));
1594     $quiz    = $DB->get_record('quiz', array('id' => $event->quizid));
1595     $cm      = get_coursemodule_from_id('quiz', $event->cmid, $event->courseid);
1596     $attempt = $DB->get_record('quiz_attempts', array('id' => $event->attemptid));
1598     if (!($course && $quiz && $cm && $attempt)) {
1599         // Something has been deleted since the event was raised. Therefore, the
1600         // event is no longer relevant.
1601         return true;
1602     }
1604     return quiz_send_overdue_message($course, $quiz, $attempt,
1605             context_module::instance($cm->id), $cm);
1608 /**
1609  * Handle groups_member_added event
1610  *
1611  * @param object $event the event object.
1612  */
1613 function quiz_groups_member_added_handler($event) {
1614     quiz_update_open_attempts(array('userid'=>$event->userid, 'groupid'=>$event->groupid));
1617 /**
1618  * Handle groups_member_removed event
1619  *
1620  * @param object $event the event object.
1621  */
1622 function quiz_groups_member_removed_handler($event) {
1623     quiz_update_open_attempts(array('userid'=>$event->userid, 'groupid'=>$event->groupid));
1626 /**
1627  * Handle groups_group_deleted event
1628  *
1629  * @param object $event the event object.
1630  */
1631 function quiz_groups_group_deleted_handler($event) {
1632     global $DB;
1634     // It would be nice if we got the groupid that was deleted.
1635     // Instead, we just update all quizzes with orphaned group overrides
1636     $sql = "SELECT o.id, o.quiz
1637               FROM {quiz_overrides} o
1638               JOIN {quiz} quiz ON quiz.id = o.quiz
1639          LEFT JOIN {groups} grp ON grp.id = o.groupid
1640              WHERE quiz.course = :courseid AND grp.id IS NULL";
1641     $params = array('courseid'=>$event->courseid);
1642     $records = $DB->get_records_sql_menu($sql, $params);
1643     if (!$records) {
1644         return; // Nothing to do.
1645     }
1646     $DB->delete_records_list('quiz_overrides', 'id', array_keys($records));
1647     quiz_update_open_attempts(array('quizid'=>array_unique(array_values($records))));
1650 /**
1651  * Handle groups_members_removed event
1652  *
1653  * @param object $event the event object.
1654  */
1655 function quiz_groups_members_removed_handler($event) {
1656     if ($event->userid == 0) {
1657         quiz_update_open_attempts(array('courseid'=>$event->courseid));
1658     } else {
1659         quiz_update_open_attempts(array('courseid'=>$event->courseid, 'userid'=>$event->userid));
1660     }
1663 /**
1664  * Get the information about the standard quiz JavaScript module.
1665  * @return array a standard jsmodule structure.
1666  */
1667 function quiz_get_js_module() {
1668     global $PAGE;
1670     return array(
1671         'name' => 'mod_quiz',
1672         'fullpath' => '/mod/quiz/module.js',
1673         'requires' => array('base', 'dom', 'event-delegate', 'event-key',
1674                 'core_question_engine', 'moodle-core-formchangechecker'),
1675         'strings' => array(
1676             array('cancel', 'moodle'),
1677             array('flagged', 'question'),
1678             array('functiondisabledbysecuremode', 'quiz'),
1679             array('startattempt', 'quiz'),
1680             array('timesup', 'quiz'),
1681             array('changesmadereallygoaway', 'moodle'),
1682         ),
1683     );
1687 /**
1688  * An extension of question_display_options that includes the extra options used
1689  * by the quiz.
1690  *
1691  * @copyright  2010 The Open University
1692  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1693  */
1694 class mod_quiz_display_options extends question_display_options {
1695     /**#@+
1696      * @var integer bits used to indicate various times in relation to a
1697      * quiz attempt.
1698      */
1699     const DURING =            0x10000;
1700     const IMMEDIATELY_AFTER = 0x01000;
1701     const LATER_WHILE_OPEN =  0x00100;
1702     const AFTER_CLOSE =       0x00010;
1703     /**#@-*/
1705     /**
1706      * @var boolean if this is false, then the student is not allowed to review
1707      * anything about the attempt.
1708      */
1709     public $attempt = true;
1711     /**
1712      * @var boolean if this is false, then the student is not allowed to review
1713      * anything about the attempt.
1714      */
1715     public $overallfeedback = self::VISIBLE;
1717     /**
1718      * Set up the various options from the quiz settings, and a time constant.
1719      * @param object $quiz the quiz settings.
1720      * @param int $one of the {@link DURING}, {@link IMMEDIATELY_AFTER},
1721      * {@link LATER_WHILE_OPEN} or {@link AFTER_CLOSE} constants.
1722      * @return mod_quiz_display_options set up appropriately.
1723      */
1724     public static function make_from_quiz($quiz, $when) {
1725         $options = new self();
1727         $options->attempt = self::extract($quiz->reviewattempt, $when, true, false);
1728         $options->correctness = self::extract($quiz->reviewcorrectness, $when);
1729         $options->marks = self::extract($quiz->reviewmarks, $when,
1730                 self::MARK_AND_MAX, self::MAX_ONLY);
1731         $options->feedback = self::extract($quiz->reviewspecificfeedback, $when);
1732         $options->generalfeedback = self::extract($quiz->reviewgeneralfeedback, $when);
1733         $options->rightanswer = self::extract($quiz->reviewrightanswer, $when);
1734         $options->overallfeedback = self::extract($quiz->reviewoverallfeedback, $when);
1736         $options->numpartscorrect = $options->feedback;
1738         if ($quiz->questiondecimalpoints != -1) {
1739             $options->markdp = $quiz->questiondecimalpoints;
1740         } else {
1741             $options->markdp = $quiz->decimalpoints;
1742         }
1744         return $options;
1745     }
1747     protected static function extract($bitmask, $bit,
1748             $whenset = self::VISIBLE, $whennotset = self::HIDDEN) {
1749         if ($bitmask & $bit) {
1750             return $whenset;
1751         } else {
1752             return $whennotset;
1753         }
1754     }
1758 /**
1759  * A {@link qubaid_condition} for finding all the question usages belonging to
1760  * a particular quiz.
1761  *
1762  * @copyright  2010 The Open University
1763  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1764  */
1765 class qubaids_for_quiz extends qubaid_join {
1766     public function __construct($quizid, $includepreviews = true, $onlyfinished = false) {
1767         $where = 'quiza.quiz = :quizaquiz';
1768         $params = array('quizaquiz' => $quizid);
1770         if (!$includepreviews) {
1771             $where .= ' AND preview = 0';
1772         }
1774         if ($onlyfinished) {
1775             $where .= ' AND state == :statefinished';
1776             $params['statefinished'] = quiz_attempt::FINISHED;
1777         }
1779         parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params);
1780     }