MDL-25637 Fixed whitespace
[moodle.git] / mod / quiz / locallib.php
1 <?php
3 ///////////////////////////////////////////////////////////////////////////
4 //                                                                       //
5 // NOTICE OF COPYRIGHT                                                   //
6 //                                                                       //
7 // Moodle - Modular Object-Oriented Dynamic Learning Environment         //
8 //          http://moodle.org                                            //
9 //                                                                       //
10 // Copyright (C) 1999 onwards Martin Dougiamas  http://dougiamas.com     //
11 //                                                                       //
12 // This program is free software; you can redistribute it and/or modify  //
13 // it under the terms of the GNU General Public License as published by  //
14 // the Free Software Foundation; either version 2 of the License, or     //
15 // (at your option) any later version.                                   //
16 //                                                                       //
17 // This program is distributed in the hope that it will be useful,       //
18 // but WITHOUT ANY WARRANTY; without even the implied warranty of        //
19 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         //
20 // GNU General Public License for more details:                          //
21 //                                                                       //
22 //          http://www.gnu.org/copyleft/gpl.html                         //
23 //                                                                       //
24 ///////////////////////////////////////////////////////////////////////////
26 /**
27  * Library of functions used by the quiz module.
28  *
29  * This contains functions that are called from within the quiz module only
30  * Functions that are also called by core Moodle are in {@link lib.php}
31  * This script also loads the code in {@link questionlib.php} which holds
32  * the module-indpendent code for handling questions and which in turn
33  * initialises all the questiontype classes.
34  *
35  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
36  * @package quiz
37  */
39 if (!defined('MOODLE_INTERNAL')) {
40     die('Direct access to this script is forbidden.'); /// It must be included from a Moodle page.
41 }
43 /**
44  * Include those library functions that are also used by core Moodle or other modules
45  */
46 require_once($CFG->dirroot . '/mod/quiz/lib.php');
47 require_once($CFG->dirroot . '/mod/quiz/accessrules.php');
48 require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
49 require_once($CFG->dirroot . '/question/editlib.php');
50 require_once($CFG->libdir  . '/eventslib.php');
52 /// Constants ///////////////////////////////////////////////////////////////////
54 /**#@+
55  * Constants to describe the various states a quiz attempt can be in.
56  */
57 define('QUIZ_STATE_DURING', 'during');
58 define('QUIZ_STATE_IMMEDIATELY', 'immedately');
59 define('QUIZ_STATE_OPEN', 'open');
60 define('QUIZ_STATE_CLOSED', 'closed');
61 define('QUIZ_STATE_TEACHERACCESS', 'teacheraccess'); // State only relevant if you are in a studenty role.
62 /**#@-*/
64 /**
65  * We show the countdown timer if there is less than this amount of time left before the
66  * the quiz close date. (1 hour)
67  */
68 define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600');
70 /// Functions related to attempts /////////////////////////////////////////
72 /**
73  * Creates an object to represent a new attempt at a quiz
74  *
75  * Creates an attempt object to represent an attempt at the quiz by the current
76  * user starting at the current time. The ->id field is not set. The object is
77  * NOT written to the database.
78  *
79  * @param object $quiz the quiz to create an attempt for.
80  * @param integer $attemptnumber the sequence number for the attempt.
81  * @param object $lastattempt the previous attempt by this user, if any. Only needed
82  *         if $attemptnumber > 1 and $quiz->attemptonlast is true.
83  * @param integer $timenow the time the attempt was started at.
84  * @param boolean $ispreview whether this new attempt is a preview.
85  *
86  * @return object the newly created attempt object.
87  */
88 function quiz_create_attempt($quiz, $attemptnumber, $lastattempt, $timenow, $ispreview = false) {
89     global $USER;
91     if ($attemptnumber == 1 || !$quiz->attemptonlast) {
92     /// We are not building on last attempt so create a new attempt.
93         $attempt = new stdClass;
94         $attempt->quiz = $quiz->id;
95         $attempt->userid = $USER->id;
96         $attempt->preview = 0;
97         if ($quiz->shufflequestions) {
98             $attempt->layout = quiz_clean_layout(quiz_repaginate($quiz->questions, $quiz->questionsperpage, true),true);
99         } else {
100             $attempt->layout = quiz_clean_layout($quiz->questions,true);
101         }
102     } else {
103     /// Build on last attempt.
104         if (empty($lastattempt)) {
105             print_error('cannotfindprevattempt', 'quiz');
106         }
107         $attempt = $lastattempt;
108     }
110     $attempt->attempt = $attemptnumber;
111     $attempt->sumgrades = 0.0;
112     $attempt->timestart = $timenow;
113     $attempt->timefinish = 0;
114     $attempt->timemodified = $timenow;
115     $attempt->uniqueid = question_new_attempt_uniqueid();
117 /// If this is a preview, mark it as such.
118     if ($ispreview) {
119         $attempt->preview = 1;
120     }
122     return $attempt;
125 /**
126  * Returns the unfinished attempt for the given
127  * user on the given quiz, if there is one.
128  *
129  * @param integer $quizid the id of the quiz.
130  * @param integer $userid the id of the user.
131  *
132  * @return mixed the unfinished attempt if there is one, false if not.
133  */
134 function quiz_get_user_attempt_unfinished($quizid, $userid) {
135     $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true);
136     if ($attempts) {
137         return array_shift($attempts);
138     } else {
139         return false;
140     }
143 /**
144  * Returns the most recent attempt by a given user on a given quiz.
145  * May be finished, or may not.
146  *
147  * @param integer $quizid the id of the quiz.
148  * @param integer $userid the id of the user.
149  *
150  * @return mixed the attempt if there is one, false if not.
151  */
152 function quiz_get_latest_attempt_by_user($quizid, $userid) {
153     global $CFG, $DB;
154     $attempt = $DB->get_records_sql('SELECT qa.* FROM {quiz_attempts} qa
155             WHERE qa.quiz=? AND qa.userid= ? ORDER BY qa.timestart DESC, qa.id DESC', array($quizid, $userid), 0, 1);
156     if ($attempt) {
157         return array_shift($attempt);
158     } else {
159         return false;
160     }
163 /**
164  * Load an attempt by id. You need to use this method instead of $DB->get_record, because
165  * of some ancient history to do with the upgrade from Moodle 1.4 to 1.5, See the comment
166  * after CREATE TABLE `prefix_quiz_newest_states` in mod/quiz/db/mysql.php.
167  *
168  * @param integer $attemptid the id of the attempt to load.
169  */
170 function quiz_load_attempt($attemptid) {
171     global $DB;
172     $attempt = $DB->get_record('quiz_attempts', array('id' => $attemptid));
173     if (!$attempt) {
174         return false;
175     }
177     if (!$DB->record_exists('question_sessions', array('attemptid' => $attempt->uniqueid))) {
178     /// this attempt has not yet been upgraded to the new model
179         quiz_upgrade_states($attempt);
180     }
182     return $attempt;
185 /**
186  * Delete a quiz attempt.
187  * @param mixed $attempt an integer attempt id or an attempt object (row of the quiz_attempts table).
188  * @param object $quiz the quiz object.
189  */
190 function quiz_delete_attempt($attempt, $quiz) {
191     global $DB;
192     if (is_numeric($attempt)) {
193         if (!$attempt = $DB->get_record('quiz_attempts', array('id' => $attempt))) {
194             return;
195         }
196     }
198     if ($attempt->quiz != $quiz->id) {
199         debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " .
200                 "but was passed quiz $quiz->id.");
201         return;
202     }
204     $DB->delete_records('quiz_attempts', array('id' => $attempt->id));
205     delete_attempt($attempt->uniqueid);
207     // Search quiz_attempts for other instances by this user.
208     // If none, then delete record for this quiz, this user from quiz_grades
209     // else recalculate best grade
211     $userid = $attempt->userid;
212     if (!$DB->record_exists('quiz_attempts', array('userid' => $userid, 'quiz' => $quiz->id))) {
213         $DB->delete_records('quiz_grades', array('userid' => $userid,'quiz' => $quiz->id));
214     } else {
215         quiz_save_best_grade($quiz, $userid);
216     }
218     quiz_update_grades($quiz, $userid);
221 /**
222  * Delete all the preview attempts at a quiz, or possibly all the attempts belonging
223  * to one user.
224  * @param object $quiz the quiz object.
225  * @param integer $userid (optional) if given, only delete the previews belonging to this user.
226  */
227 function quiz_delete_previews($quiz, $userid = null) {
228     global $DB;
229     $conditions = array('quiz' => $quiz->id, 'preview' => 1);
230     if (!empty($userid)) {
231         $conditions['userid'] = $userid;
232     }
233     $previewattempts = $DB->get_records('quiz_attempts', $conditions);
234     foreach ($previewattempts as $attempt) {
235         quiz_delete_attempt($attempt, $quiz);
236     }
239 /**
240  * @param integer $quizid The quiz id.
241  * @return boolean whether this quiz has any (non-preview) attempts.
242  */
243 function quiz_has_attempts($quizid) {
244     global $DB;
245     return $DB->record_exists('quiz_attempts', array('quiz' => $quizid, 'preview' => 0));
248 /// Functions to do with quiz layout and pages ////////////////////////////////
250 /**
251  * Returns a comma separated list of question ids for the current page
252  *
253  * @param string $layout the string representing the quiz layout. Each page is represented as a
254  *      comma separated list of question ids and 0 indicating page breaks.
255  *      So 5,2,0,3,0 means questions 5 and 2 on page 1 and question 3 on page 2
256  * @param integer $page the number of the current page.
257  * @return string comma separated list of question ids
258  */
259 function quiz_questions_on_page($layout, $page) {
260     $pages = explode(',0', $layout);
261     return trim($pages[$page], ',');
264 /**
265  * Returns a comma separated list of question ids for the quiz
266  *
267  * @param string $layout The string representing the quiz layout. Each page is
268  *      represented as a comma separated list of question ids and 0 indicating
269  *      page breaks. So 5,2,0,3,0 means questions 5 and 2 on page 1 and question
270  *      3 on page 2
271  * @return string comma separated list of question ids, without page breaks.
272  */
273 function quiz_questions_in_quiz($layout) {
274     $layout = preg_replace('/,(0+,)+/', ',', $layout); // Remove page breaks from the middle.
275     $layout = preg_replace('/^0+,/', '', $layout); // And from the start.
276     $layout = preg_replace('/(^|,)0+$/', '', $layout); // And from the end.
277     return $layout;
280 /**
281  * Returns the number of pages in a quiz layout
282  *
283  * @param string $layout The string representing the quiz layout. Always ends in ,0
284  * @return integer The number of pages in the quiz.
285  */
286 function quiz_number_of_pages($layout) {
287     $count = 0;
288     if ($layout !== '') {
289         //if the first page is empty, include it, too
290         if (strcmp($layout[0], '0') === 0) {
291             $count++;
292         }
293         $count += substr_count($layout, ',0');
294     }
295     return $count;
297 /**
298  * Returns the number of questions in the quiz layout
299  *
300  * @param string $layout the string representing the quiz layout.
301  * @return integer The number of questions in the quiz.
302  */
303 function quiz_number_of_questions_in_quiz($layout) {
304     $layout = quiz_questions_in_quiz(quiz_clean_layout($layout));
305     $count = substr_count($layout, ',');
306     if ($layout !== '') {
307         $count++;
308     }
309     return $count;
312 /**
313  * Returns the first question number for the current quiz page
314  *
315  * @param string $quizlayout The string representing the layout for the whole quiz
316  * @param string $pagelayout The string representing the layout for the current page
317  * @return integer the number of the first question
318  */
319 function quiz_first_questionnumber($quizlayout, $pagelayout) {
320     // this works by finding all the questions from the quizlayout that
321     // come before the current page and then adding up their lengths.
322     global $CFG, $DB;
323     $start = strpos($quizlayout, ','.$pagelayout.',')-2;
324     if ($start > 0) {
325         $prevlist = substr($quizlayout, 0, $start);
326         list($usql, $params) = $DB->get_in_or_equal(explode(',', $prevlist));
327         return $DB->get_field_sql("SELECT sum(length)+1 FROM {question}
328          WHERE id $usql", $params);
329     } else {
330         return 1;
331     }
334 /**
335  * Re-paginates the quiz layout
336  *
337  * @param string $layout  The string representing the quiz layout.
338  * @param integer $perpage The number of questions per page
339  * @param boolean $shuffle Should the questions be reordered randomly?
340  * @return string the new layout string
341  */
342 function quiz_repaginate($layout, $perpage, $shuffle = false) {
343     $layout = str_replace(',0', '', $layout); // remove existing page breaks
344     $questions = explode(',', $layout);
345     //remove empty pages from beginning
346     while (reset($questions) == '0') {
347         array_shift($questions);
348     }
349     if ($shuffle) {
350         shuffle($questions);
351     }
352     $i = 1;
353     $layout = '';
354     foreach ($questions as $question) {
355         if ($perpage and $i > $perpage) {
356             $layout .= '0,';
357             $i = 1;
358         }
359         $layout .= $question.',';
360         $i++;
361     }
362     return $layout.'0';
365 /// Functions to do with quiz grades //////////////////////////////////////////
367 /**
368  * Creates an array of maximum grades for a quiz
369  * The grades are extracted from the quiz_question_instances table.
370  *
371  * @param integer $quiz The quiz object
372  * @return array Array of grades indexed by question id. These are the maximum
373  *      possible grades that students can achieve for each of the questions.
374  */
375 function quiz_get_all_question_grades($quiz) {
376     global $CFG, $DB;
378     $questionlist = quiz_questions_in_quiz($quiz->questions);
379     if (empty($questionlist)) {
380         return array();
381     }
383     $params = array($quiz->id);
384     $wheresql = '';
385     if (!is_null($questionlist)) {
386         list($usql, $question_params) = $DB->get_in_or_equal(explode(',', $questionlist));
387         $wheresql = " AND question $usql ";
388         $params = array_merge($params, $question_params);
389     }
391     $instances = $DB->get_records_sql("SELECT question,grade,id
392                                     FROM {quiz_question_instances}
393                                     WHERE quiz = ? $wheresql", $params);
395     $list = explode(",", $questionlist);
396     $grades = array();
398     foreach ($list as $qid) {
399         if (isset($instances[$qid])) {
400             $grades[$qid] = $instances[$qid]->grade;
401         } else {
402             $grades[$qid] = 1;
403         }
404     }
405     return $grades;
408 /**
409  * Update the sumgrades field of the quiz. This needs to be called whenever
410  * the grading structure of the quiz is changed. For example if a question is
411  * added or removed, or a question weight is changed.
412  *
413  * @param object $quiz a quiz.
414  */
415 function quiz_update_sumgrades($quiz) {
416     global $DB;
417     $grades = quiz_get_all_question_grades($quiz);
418     $sumgrades = 0;
419     foreach ($grades as $grade) {
420         $sumgrades += $grade;
421     }
422     if (!isset($quiz->sumgrades) || $quiz->sumgrades != $sumgrades) {
423         $DB->set_field('quiz', 'sumgrades', $sumgrades, array('id' => $quiz->id));
424         $quiz->sumgrades = $sumgrades;
425     }
428 /**
429  * Convert the raw grade stored in $attempt into a grade out of the maximum
430  * grade for this quiz.
431  *
432  * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades
433  * @param object $quiz the quiz object. Only the fields grade, sumgrades, decimalpoints and questiondecimalpoints are used.
434  * @param mixed $round false = don't round, true = round using quiz_format_grade, 'question' = round using quiz_format_question_grade.
435  * @return float the rescaled grade.
436  */
437 function quiz_rescale_grade($rawgrade, $quiz, $round = true) {
438     if ($quiz->sumgrades != 0) {
439         $grade = $rawgrade * $quiz->grade / $quiz->sumgrades;
440         if ($round === 'question') { // === really necessary here true == 'question' is true in PHP!
441             $grade = quiz_format_question_grade($quiz, $grade);
442         } else if ($round) {
443             $grade = quiz_format_grade($quiz, $grade);
444         }
445     } else {
446         $grade = 0;
447     }
448     return $grade;
451 /**
452  * Get the feedback text that should be show to a student who
453  * got this grade on this quiz. The feedback is processed ready for diplay.
454  *
455  * @param float $grade a grade on this quiz.
456  * @param integer $quizid the id of the quiz object.
457  * @return string the comment that corresponds to this grade (empty string if there is not one.
458  */
459 function quiz_feedback_for_grade($grade, $quiz, $context, $cm=null) {
460     global $DB;
462     $feedback = $DB->get_record_select('quiz_feedback', "quizid = ? AND mingrade <= ? AND $grade < maxgrade", array($quiz->id, $grade));
464     if (empty($feedback->feedbacktext)) {
465         $feedback->feedbacktext = '';
466     }
468     // Clean the text, ready for display.
469     $formatoptions = new stdClass;
470     $formatoptions->noclean = true;
471     $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext, 'pluginfile.php', $context->id, 'mod_quiz', 'feedback', $feedback->id);
472     $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions);
474     return $feedbacktext;
477 /**
478  * @param object $quiz the quiz database row.
479  * @return boolean Whether this quiz has any non-blank feedback text.
480  */
481 function quiz_has_feedback($quiz) {
482     global $DB;
483     static $cache = array();
484     if (!array_key_exists($quiz->id, $cache)) {
485         $cache[$quiz->id] = quiz_has_grades($quiz) &&
486                 $DB->record_exists_select('quiz_feedback', "quizid = ? AND " .
487                     $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true),
488                 array($quiz->id));
489     }
490     return $cache[$quiz->id];
493 /**
494  * The quiz grade is the score that student's results are marked out of. When it
495  * changes, the corresponding data in quiz_grades and quiz_feedback needs to be
496  * rescaled.
497  *
498  * @param float $newgrade the new maximum grade for the quiz.
499  * @param object $quiz the quiz we are updating. Passed by reference so its grade field can be updated too.
500  * @return boolean indicating success or failure. TODO: MDL-20625
501  */
502 function quiz_set_grade($newgrade, &$quiz) {
503     global $DB;
504     // This is potentially expensive, so only do it if necessary.
505     if (abs($quiz->grade - $newgrade) < 1e-7) {
506         // Nothing to do.
507         return true;
508     }
510     // Use a transaction, so that on those databases that support it, this is safer.
511     $transaction = $DB->start_delegated_transaction();
513     try {
514         // Update the quiz table.
515         $DB->set_field('quiz', 'grade', $newgrade, array('id' => $quiz->instance));
517         // Rescaling the other data is only possible if the old grade was non-zero.
518         if ($quiz->grade > 1e-7) {
519             global $CFG;
521             $factor = $newgrade/$quiz->grade;
522             $quiz->grade = $newgrade;
524             // Update the quiz_grades table.
525             $timemodified = time();
526             $DB->execute("
527                     UPDATE {quiz_grades}
528                     SET grade = ? * grade, timemodified = ?
529                     WHERE quiz = ?
530             ", array($factor, $timemodified, $quiz->id));
532             // Update the quiz_feedback table.
533             $DB->execute("
534                     UPDATE {quiz_feedback}
535                     SET mingrade = ? * mingrade, maxgrade = ? * maxgrade
536                     WHERE quizid = ?
537             ", array($factor, $factor, $quiz->id));
538         }
540         // update grade item and send all grades to gradebook
541         quiz_grade_item_update($quiz);
542         quiz_update_grades($quiz);
544         $transaction->allow_commit();
545         return true;
547     } catch (Exception $e) {
548         //TODO: MDL-20625 this part was returning false, but now throws exception
549         $transaction->rollback($e);
550     }
553 /**
554  * Save the overall grade for a user at a quiz in the quiz_grades table
555  *
556  * @param object $quiz The quiz for which the best grade is to be calculated and then saved.
557  * @param integer $userid The userid to calculate the grade for. Defaults to the current user.
558  * @param array $attempts The attempts of this user. Useful if you are
559  * looping through many users. Attempts can be fetched in one master query to
560  * avoid repeated querying.
561  * @return boolean Indicates success or failure.
562  */
563 function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) {
564     global $DB;
565     global $USER, $OUTPUT;
567     if (empty($userid)) {
568         $userid = $USER->id;
569     }
571     if (!$attempts){
572         // Get all the attempts made by the user
573         if (!$attempts = quiz_get_user_attempts($quiz->id, $userid)) {
574             echo $OUTPUT->notification('Could not find any user attempts');
575             return false;
576         }
577     }
579     // Calculate the best grade
580     $bestgrade = quiz_calculate_best_grade($quiz, $attempts);
581     $bestgrade = quiz_rescale_grade($bestgrade, $quiz, false);
583     // Save the best grade in the database
584     if ($grade = $DB->get_record('quiz_grades', array('quiz' => $quiz->id, 'userid' => $userid))) {
585         $grade->grade = $bestgrade;
586         $grade->timemodified = time();
587         $DB->update_record('quiz_grades', $grade);
588     } else {
589         $grade->quiz = $quiz->id;
590         $grade->userid = $userid;
591         $grade->grade = $bestgrade;
592         $grade->timemodified = time();
593         $DB->insert_record('quiz_grades', $grade);
594     }
596     quiz_update_grades($quiz, $userid);
597     return true;
600 /**
601  * Calculate the overall grade for a quiz given a number of attempts by a particular user.
602  *
603  * @return float          The overall grade
604  * @param object $quiz    The quiz for which the best grade is to be calculated
605  * @param array $attempts An array of all the attempts of the user at the quiz
606  */
607 function quiz_calculate_best_grade($quiz, $attempts) {
609     switch ($quiz->grademethod) {
611         case QUIZ_ATTEMPTFIRST:
612             foreach ($attempts as $attempt) {
613                 return $attempt->sumgrades;
614             }
615             break;
617         case QUIZ_ATTEMPTLAST:
618             foreach ($attempts as $attempt) {
619                 $final = $attempt->sumgrades;
620             }
621             return $final;
623         case QUIZ_GRADEAVERAGE:
624             $sum = 0;
625             $count = 0;
626             foreach ($attempts as $attempt) {
627                 $sum += $attempt->sumgrades;
628                 $count++;
629             }
630             return (float)$sum/$count;
632         default:
633         case QUIZ_GRADEHIGHEST:
634             $max = 0;
635             foreach ($attempts as $attempt) {
636                 if ($attempt->sumgrades > $max) {
637                     $max = $attempt->sumgrades;
638                 }
639             }
640             return $max;
641     }
644 /**
645  * Return the attempt with the best grade for a quiz
646  *
647  * Which attempt is the best depends on $quiz->grademethod. If the grade
648  * method is GRADEAVERAGE then this function simply returns the last attempt.
649  * @return object         The attempt with the best grade
650  * @param object $quiz    The quiz for which the best grade is to be calculated
651  * @param array $attempts An array of all the attempts of the user at the quiz
652  */
653 function quiz_calculate_best_attempt($quiz, $attempts) {
655     switch ($quiz->grademethod) {
657         case QUIZ_ATTEMPTFIRST:
658             foreach ($attempts as $attempt) {
659                 return $attempt;
660             }
661             break;
663         case QUIZ_GRADEAVERAGE: // need to do something with it :-)
664         case QUIZ_ATTEMPTLAST:
665             foreach ($attempts as $attempt) {
666                 $final = $attempt;
667             }
668             return $final;
670         default:
671         case QUIZ_GRADEHIGHEST:
672             $max = -1;
673             foreach ($attempts as $attempt) {
674                 if ($attempt->sumgrades > $max) {
675                     $max = $attempt->sumgrades;
676                     $maxattempt = $attempt;
677                 }
678             }
679             return $maxattempt;
680     }
683 /**
684  * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST.
685  * @return the lang string for that option.
686  */
687 function quiz_get_grading_option_name($option) {
688     $strings = quiz_get_grading_options();
689     return $strings[$option];
692 /// Other quiz functions ////////////////////////////////////////////////////
694 /**
695  * Parse field names used for the replace options on question edit forms
696  */
697 function quiz_parse_fieldname($name, $nameprefix='question') {
698     $reg = array();
699     if (preg_match("/$nameprefix(\\d+)(\w+)/", $name, $reg)) {
700         return array('mode' => $reg[2], 'id' => (int)$reg[1]);
701     } else {
702         return false;
703     }
706 /**
707  * Upgrade states for an attempt to Moodle 1.5 model
708  *
709  * Any state that does not yet have its timestamp set to nonzero has not yet been upgraded from Moodle 1.4
710  * The reason these are still around is that for large sites it would have taken too long to
711  * upgrade all states at once. This function sets the timestamp field and creates an entry in the
712  * question_sessions table.
713  * @param object $attempt  The attempt whose states need upgrading
714  */
715 function quiz_upgrade_states($attempt) {
716     global $DB;
717     global $CFG;
718     // The old quiz model only allowed a single response per quiz attempt so that there will be
719     // only one state record per question for this attempt.
721     // We set the timestamp of all states to the timemodified field of the attempt.
722     $DB->execute("UPDATE {question_states} SET timestamp = ? WHERE attempt = ?", array($attempt->timemodified, $attempt->uniqueid));
724     // For each state we create an entry in the question_sessions table, with both newest and
725     // newgraded pointing to this state.
726     // Actually we only do this for states whose question is actually listed in $attempt->layout.
727     // We do not do it for states associated to wrapped questions like for example the questions
728     // used by a RANDOM question
729     $session = new stdClass;
730     $session->attemptid = $attempt->uniqueid;
731     $questionlist = quiz_questions_in_quiz($attempt->layout);
732     $params = array($attempt->uniqueid);
733     list($usql, $question_params) = $DB->get_in_or_equal(explode(',',$questionlist));
734     $params = array_merge($params, $question_params);
736     if ($questionlist and $states = $DB->get_records_select('question_states', "attempt = ? AND question $usql", $params)) {
737         foreach ($states as $state) {
738             $session->newgraded = $state->id;
739             $session->newest = $state->id;
740             $session->questionid = $state->question;
741             $DB->insert_record('question_sessions', $session, false);
742         }
743     }
746 /**
747  * @param object $quiz the quiz.
748  * @param integer $cmid the course_module object for this quiz.
749  * @param object $question the question.
750  * @param string $returnurl url to return to after action is done.
751  * @return string html for a number of icons linked to action pages for a
752  * question - preview and edit / view icons depending on user capabilities.
753  */
754 function quiz_question_action_icons($quiz, $cmid, $question, $returnurl) {
755     $html = quiz_question_preview_button($quiz, $question) . ' ' .
756             quiz_question_edit_button($cmid, $question, $returnurl);
757     return $html;
760 /**
761  * @param integer $cmid the course_module.id for this quiz.
762  * @param object $question the question.
763  * @param string $returnurl url to return to after action is done.
764  * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon.
765  * @return the HTML for an edit icon, view icon, or nothing for a question (depending on permissions).
766  */
767 function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') {
768     global $CFG, $OUTPUT;
770     // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page.
771     static $stredit = null;
772     static $strview = null;
773     if ($stredit === null){
774         $stredit = get_string('edit');
775         $strview = get_string('view');
776     }
778     // What sort of icon should we show?
779     $action = '';
780     if (question_has_capability_on($question, 'edit', $question->category) ||
781             question_has_capability_on($question, 'move', $question->category)) {
782         $action = $stredit;
783         $icon = '/t/edit';
784     } else if (question_has_capability_on($question, 'view', $question->category)) {
785         $action = $strview;
786         $icon = '/i/info';
787     }
789     // Build the icon.
790     if ($action) {
791         $questionparams = array('returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id);
792         $questionurl = new moodle_url("$CFG->wwwroot/question/question.php", $questionparams);
793         return '<a title="' . $action . '" href="' . $questionurl->out() . '"><img src="' .
794                 $OUTPUT->pix_url($icon) . '" alt="' . $action . '" />' . $contentaftericon .
795                 '</a>';
796     } else {
797         return $contentaftericon;
798     }
801 /**
802  * @param object $quiz the quiz
803  * @param object $question the question
804  * @param boolean $label if true, show the previewquestion label after the icon
805  * @return the HTML for a preview question icon.
806  */
807 function quiz_question_preview_button($quiz, $question, $label = false) {
808     global $CFG, $COURSE, $OUTPUT;
809     if (!question_has_capability_on($question, 'use', $question->category)) {
810         return '';
811     }
813     // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page.
814     static $strpreview = null;
815     static $strpreviewquestion = null;
816     if ($strpreview === null){
817         $strpreview = get_string('preview', 'quiz');
818         $strpreviewquestion = get_string('previewquestion', 'quiz');
819     }
821     // Do we want a label?
822     $strpreviewlabel="";
823     if ($label) {
824         $strpreviewlabel = $strpreview;
825     }
827     // Build the icon.
828     $image = $OUTPUT->pix_icon('t/preview', $strpreviewquestion);
830     $link = new moodle_url($CFG->wwwroot."/question/preview.php?id=$question->id&quizid=$quiz->id");
831     parse_str(QUESTION_PREVIEW_POPUP_OPTIONS, $options);
832     $action = new popup_action('click', $link, 'questionpreview', $options);
834     return $OUTPUT->action_link($link, $image, $action, array('title' => $strpreviewquestion));
837 /**
838  * @param object $attempt the attempt.
839  * @param object $context the quiz context.
840  * @return integer whether flags should be shown/editable to the current user for this attempt.
841  */
842 function quiz_get_flag_option($attempt, $context) {
843     global $USER;
844     static $flagmode = null;
845     if (is_null($flagmode)) {
846         if (!has_capability('moodle/question:flag', $context)) {
847             $flagmode = QUESTION_FLAGSHIDDEN;
848         } else if ($attempt->userid == $USER->id) {
849             $flagmode = QUESTION_FLAGSEDITABLE;
850         } else {
851             $flagmode = QUESTION_FLAGSSHOWN;
852         }
853     }
854     return $flagmode;
857 /**
858  * Determine render options
859  *
860  * @param int $reviewoptions
861  * @param object $state
862  */
863 function quiz_get_renderoptions($quiz, $attempt, $context, $state) {
864     $reviewoptions = $quiz->review;
865     $options = new stdClass;
867     $options->flags = quiz_get_flag_option($attempt, $context);
869     // Show the question in readonly (review) mode if the question is in
870     // the closed state
871     $options->readonly = question_state_is_closed($state);
873     // Show feedback once the question has been graded (if allowed by the quiz)
874     $options->feedback = question_state_is_graded($state) && ($reviewoptions & QUIZ_REVIEW_FEEDBACK & QUIZ_REVIEW_IMMEDIATELY);
876     // Show correct responses in readonly mode if the quiz allows it
877     $options->correct_responses = $options->readonly && ($reviewoptions & QUIZ_REVIEW_ANSWERS & QUIZ_REVIEW_IMMEDIATELY);
879     // Show general feedback if the question has been graded and the quiz allows it.
880     $options->generalfeedback = question_state_is_graded($state) && ($reviewoptions & QUIZ_REVIEW_GENERALFEEDBACK & QUIZ_REVIEW_IMMEDIATELY);
882     // Show overallfeedback once the attempt is over.
883     $options->overallfeedback = false;
885     // Always show responses and scores
886     $options->responses = true;
887     $options->scores = true;
888     $options->quizstate = QUIZ_STATE_DURING;
889     $options->history = false;
891     return $options;
894 /**
895  * Determine review options
896  *
897  * @param object $quiz the quiz instance.
898  * @param object $attempt the attempt in question.
899  * @param $context the quiz module context.
900  *
901  * @return object an object with boolean fields responses, scores, feedback,
902  *          correct_responses, solutions and general feedback
903  */
904 function quiz_get_reviewoptions($quiz, $attempt, $context) {
905     global $USER;
907     $options = new stdClass;
908     $options->readonly = true;
910     $options->flags = quiz_get_flag_option($attempt, $context);
912     // Provide the links to the question review and comment script
913     if (!empty($attempt->id)) {
914         $options->questionreviewlink = '/mod/quiz/reviewquestion.php?attempt=' . $attempt->id;
915     }
917     // Show a link to the comment box only for closed attempts
918     if ($attempt->timefinish && has_capability('mod/quiz:grade', $context)) {
919         $options->questioncommentlink = '/mod/quiz/comment.php';
920     }
922     // Whether to display a response history.
923     $canviewreports = has_capability('mod/quiz:viewreports', $context);
924     $options->history = ($canviewreports && !$attempt->preview) ? 'all' : 'graded';
926     if ($canviewreports && has_capability('moodle/grade:viewhidden', $context) && !$attempt->preview) {
927         // People who can see reports and hidden grades should be shown everything,
928         // except during preview when teachers want to see what students see.
929         $options->responses = true;
930         $options->scores = true;
931         $options->feedback = true;
932         $options->correct_responses = true;
933         $options->solutions = false;
934         $options->generalfeedback = true;
935         $options->overallfeedback = true;
936         $options->quizstate = QUIZ_STATE_TEACHERACCESS;
937     } else {
938         // Work out the state of the attempt ...
939         if (((time() - $attempt->timefinish) < 120) || $attempt->timefinish==0) {
940             $quiz_state_mask = QUIZ_REVIEW_IMMEDIATELY;
941             $options->quizstate = QUIZ_STATE_IMMEDIATELY;
942         } else if (!$quiz->timeclose or time() < $quiz->timeclose) {
943             $quiz_state_mask = QUIZ_REVIEW_OPEN;
944             $options->quizstate = QUIZ_STATE_OPEN;
945         } else {
946             $quiz_state_mask = QUIZ_REVIEW_CLOSED;
947             $options->quizstate = QUIZ_STATE_CLOSED;
948         }
950         // ... and hence extract the appropriate review options.
951         $options->responses = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_RESPONSES) ? 1 : 0;
952         $options->scores = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_SCORES) ? 1 : 0;
953         $options->feedback = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_FEEDBACK) ? 1 : 0;
954         $options->correct_responses = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_ANSWERS) ? 1 : 0;
955         $options->solutions = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_SOLUTIONS) ? 1 : 0;
956         $options->generalfeedback = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_GENERALFEEDBACK) ? 1 : 0;
957         $options->overallfeedback = $attempt->timefinish && ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_OVERALLFEEDBACK);
958     }
960     return $options;
963 /**
964  * Combines the review options from a number of different quiz attempts.
965  * Returns an array of two ojects, so he suggested way of calling this
966  * funciton is:
967  * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...)
968  *
969  * @param object $quiz the quiz instance.
970  * @param array $attempts an array of attempt objects.
971  * @param $context the roles and permissions context,
972  *          normally the context for the quiz module instance.
973  *
974  * @return array of two options objects, one showing which options are true for
975  *          at least one of the attempts, the other showing which options are true
976  *          for all attempts.
977  */
978 function quiz_get_combined_reviewoptions($quiz, $attempts, $context) {
979     $fields = array('readonly', 'scores', 'feedback', 'correct_responses', 'solutions', 'generalfeedback', 'overallfeedback');
980     $someoptions = new stdClass;
981     $alloptions = new stdClass;
982     foreach ($fields as $field) {
983         $someoptions->$field = false;
984         $alloptions->$field = true;
985     }
986     foreach ($attempts as $attempt) {
987         $attemptoptions = quiz_get_reviewoptions($quiz, $attempt, $context);
988         foreach ($fields as $field) {
989             $someoptions->$field = $someoptions->$field || $attemptoptions->$field;
990             $alloptions->$field = $alloptions->$field && $attemptoptions->$field;
991         }
992     }
993     return array($someoptions, $alloptions);
996 /// FUNCTIONS FOR SENDING NOTIFICATION EMAILS ///////////////////////////////
998 /**
999  * Sends confirmation email to the student taking the course
1000  *
1001  * @param stdClass $a associative array of replaceable fields for the templates
1002  *
1003  * @return bool|string result of events_triger
1004  */
1005 function quiz_send_confirmation($a) {
1007     global $USER;
1009     // recipient is self
1010     $a->useridnumber = $USER->idnumber;
1011     $a->username = fullname($USER);
1012     $a->userusername = $USER->username;
1014     // fetch the subject and body from strings
1015     $subject = get_string('emailconfirmsubject', 'quiz', $a);
1016     $body = get_string('emailconfirmbody', 'quiz', $a);
1018     // send email and analyse result
1019     $eventdata = new stdClass();
1020     $eventdata->component        = 'mod_quiz';
1021     $eventdata->name             = 'confirmation';
1022     $eventdata->notification      = 1;
1024     $eventdata->userfrom          = get_admin();
1025     $eventdata->userto            = $USER;
1026     $eventdata->subject           = $subject;
1027     $eventdata->fullmessage       = $body;
1028     $eventdata->fullmessageformat = FORMAT_PLAIN;
1029     $eventdata->fullmessagehtml   = '';
1031     $eventdata->smallmessage      = get_string('emailconfirmsmall', 'quiz', $a);
1032     $eventdata->contexturl        = $a->quizurl;
1033     $eventdata->contexturlname    = $a->quizname;
1034     
1035     return message_send($eventdata);
1038 /**
1039  * Sends notification messages to the interested parties that assign the role capability
1040  *
1041  * @param object $recipient user object of the intended recipient
1042  * @param stdClass $a associative array of replaceable fields for the templates
1043  *
1044  * @return bool|string result of events_triger()
1045  */
1046 function quiz_send_notification($recipient, $a) {
1048     global $USER;
1050     // recipient info for template
1051     $a->username = fullname($recipient);
1052     $a->userusername = $recipient->username;
1053     //$a->userusername = $recipient->username;
1055     // fetch the subject and body from strings
1056     $subject = get_string('emailnotifysubject', 'quiz', $a);
1057     $body = get_string('emailnotifybody', 'quiz', $a);
1059     // send email and analyse result
1060     $eventdata = new stdClass();
1061     $eventdata->component        = 'mod_quiz';
1062     $eventdata->name             = 'submission';
1063     $eventdata->notification      = 1;
1065     $eventdata->userfrom          = $USER;
1066     $eventdata->userto            = $recipient;
1067     $eventdata->subject           = $subject;
1068     $eventdata->fullmessage       = $body;
1069     $eventdata->fullmessageformat = FORMAT_PLAIN;
1070     $eventdata->fullmessagehtml   = '';
1072     $eventdata->smallmessage      = get_string('emailnotifysmall', 'quiz', $a);
1073     $eventdata->contexturl        = $a->quizreviewurl;
1074     $eventdata->contexturlname    = $a->quizname;
1076     return message_send($eventdata);
1079 /**
1080  * Takes a bunch of information to format into an email and send
1081  * to the specified recipient.
1082  *
1083  * @param object $course the course
1084  * @param object $quiz the quiz
1085  * @param object $attempt this attempt just finished
1086  * @param object $context the quiz context
1087  * @param object $cm the coursemodule for this quiz
1088  *
1089  * @return int number of emails sent
1090  */
1091 function quiz_send_notification_emails($course, $quiz, $attempt, $context, $cm) {
1092     global $CFG, $USER;
1093     // we will count goods and bads for error logging
1094     $emailresult = array('good' => 0, 'fail' => 0);
1096     // do nothing if required objects not present
1097     if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1098         debugging('quiz_send_notification_emails: Email(s) not sent due to program error.',
1099                 DEBUG_DEVELOPER);
1100         return $emailresult['fail'];
1101     }
1103     // check for confirmation required
1104     $sendconfirm = false;
1105     $notifyexcludeusers = '';
1106     if (has_capability('mod/quiz:emailconfirmsubmission', $context, NULL, false)) {
1107         // exclude from notify emails later
1108         $notifyexcludeusers = $USER->id;
1109         // send the email
1110         $sendconfirm = true;
1111     }
1113     // check for notifications required
1114     $notifyfields = 'u.id, u.username, u.firstname, u.lastname, u.email, u.lang, u.timezone, u.mailformat, u.maildisplay';
1115     $groups = groups_get_all_groups($course->id, $USER->id);
1116     if (is_array($groups) && count($groups) > 0) {
1117         $groups = array_keys($groups);
1118     } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) {
1119         // If the user is not in a group, and the quiz is set to group mode,
1120         // then set $gropus to a non-existant id so that only users with
1121         // 'moodle/site:accessallgroups' get notified.
1122         $groups = -1;
1123     } else {
1124         $groups = '';
1125     }
1126     $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission',
1127             $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true);
1129     // if something to send, then build $a
1130     if (! empty($userstonotify) or $sendconfirm) {
1131         $a = new stdClass;
1132         // course info
1133         $a->coursename = $course->fullname;
1134         $a->courseshortname = $course->shortname;
1135         // quiz info
1136         $a->quizname = $quiz->name;
1137         $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id;
1138         $a->quizreportlink = '<a href="' . $a->quizreporturl . '">' . format_string($quiz->name) . ' report</a>';
1139         $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id;
1140         $a->quizreviewlink = '<a href="' . $a->quizreviewurl . '">' . format_string($quiz->name) . ' review</a>';
1141         $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id;
1142         $a->quizlink = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>';
1143         // attempt info
1144         $a->submissiontime = userdate($attempt->timefinish);
1145         $a->timetaken = format_time($attempt->timefinish - $attempt->timestart);
1146         // student who sat the quiz info
1147         $a->studentidnumber = $USER->idnumber;
1148         $a->studentname = fullname($USER);
1149         $a->studentusername = $USER->username;
1150     }
1152     // send confirmation if required
1153     if ($sendconfirm) {
1154         // send the email and update stats
1155         switch (quiz_send_confirmation($a)) {
1156             case true:
1157                 $emailresult['good']++;
1158                 break;
1159             case false:
1160                 $emailresult['fail']++;
1161                 break;
1162         }
1163     }
1165     // send notifications if required
1166     if (!empty($userstonotify)) {
1167         // loop through recipients and send an email to each and update stats
1168         foreach ($userstonotify as $recipient) {
1169             switch (quiz_send_notification($recipient, $a)) {
1170                 case true:
1171                     $emailresult['good']++;
1172                     break;
1173                 case false:
1174                     $emailresult['fail']++;
1175                     break;
1176             }
1177         }
1178     }
1180     // log errors sending emails if any
1181     if (! empty($emailresult['fail'])) {
1182         debugging('quiz_send_notification_emails:: '.$emailresult['fail'].' email(s) failed to be sent.', DEBUG_DEVELOPER);
1183     }
1185     // return the number of successfully sent emails
1186     return $emailresult['good'];
1189 /**
1190  * Clean the question layout from various possible anomalies:
1191  * - Remove consecutive ","'s
1192  * - Remove duplicate question id's
1193  * - Remove extra "," from beginning and end
1194  * - Finally, add a ",0" in the end if there is none
1195  *
1196  * @param $string $layout the quiz layout to clean up, usually from $quiz->questions.
1197  * @param boolean $removeemptypages If true, remove empty pages from the quiz. False by default.
1198  * @return $string the cleaned-up layout
1199  */
1200 function quiz_clean_layout($layout, $removeemptypages = false){
1201     // Remove duplicate "," (or triple, or...)
1202     $layout = preg_replace('/,{2,}/', ',', trim($layout, ','));
1204     // Remove duplicate question ids
1205     $layout = explode(',', $layout);
1206     $cleanerlayout = array();
1207     $seen = array();
1208     foreach ($layout as $item) {
1209         if ($item == 0) {
1210             $cleanerlayout[] = '0';
1211         } else if (!in_array($item, $seen)) {
1212             $cleanerlayout[] = $item;
1213             $seen[] = $item;
1214         }
1215     }
1217     if ($removeemptypages) {
1218         // Avoid duplicate page breaks
1219         $layout = $cleanerlayout;
1220         $cleanerlayout = array();
1221         $stripfollowingbreaks = true; // Ensure breaks are stripped from the start.
1222         foreach ($layout as $item) {
1223             if ($stripfollowingbreaks && $item == 0) {
1224                 continue;
1225             }
1226             $cleanerlayout[] = $item;
1227             $stripfollowingbreaks = $item == 0;
1228         }
1229     }
1231     // Add a page break at the end if there is none
1232     if (end($cleanerlayout) !== '0') {
1233         $cleanerlayout[] = '0';
1234     }
1236     return implode(',', $cleanerlayout);
1238 /**
1239  * Print a quiz error message. This is a thin wrapper around print_error, for convinience.
1240  *
1241  * @param mixed $quiz either the quiz object, or the interger quiz id.
1242  * @param string $errorcode the name of the string from quiz.php to print.
1243  * @param object $a any extra data required by the error string.
1244  */
1245 function quiz_error($quiz, $errorcode, $a = null) {
1246     global $CFG;
1247     if (is_object($quiz)) {
1248         $quiz = $quiz->id;
1249     }
1250     print_error($errorcode, 'quiz', $CFG->wwwroot . '/mod/quiz/view.php?q=' . $quiz, $a);
1253 /**
1254  * Checks if browser is safe browser
1255  *
1256  * @return true, if browser is safe browser else false
1257 */
1258 function quiz_check_safe_browser() {
1259     return strpos($_SERVER['HTTP_USER_AGENT'], "SEB") !== false;
1262 function quiz_get_js_module() {
1263     global $PAGE;
1264     return array(
1265         'name' => 'mod_quiz',
1266         'fullpath' => '/mod/quiz/module.js',
1267         'requires' => array('base', 'dom', 'event-delegate', 'event-key', 'core_question_engine'),
1268         'strings' => array(
1269             array('timesup', 'quiz'),
1270             array('functiondisabledbysecuremode', 'quiz'),
1271             array('flagged', 'question'),
1272         ),
1273     );