MDL-16263 A way for students to flag/bookmark, particular questions during a quiz...
[moodle.git] / mod / quiz / locallib.php
1 <?php  // $Id$
2 /**
3  * Library of functions used by the quiz module.
4  *
5  * This contains functions that are called from within the quiz module only
6  * Functions that are also called by core Moodle are in {@link lib.php}
7  * This script also loads the code in {@link questionlib.php} which holds
8  * the module-indpendent code for handling questions and which in turn
9  * initialises all the questiontype classes.
10  *
11  * @author Martin Dougiamas and many others. This has recently been completely
12  *         rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of
13  *         the Serving Mathematics project
14  *         {@link http://maths.york.ac.uk/serving_maths}
15  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
16  * @package quiz
17  */
19 if (!defined('MOODLE_INTERNAL')) {
20     die('Direct access to this script is forbidden.'); /// It must be included from a Moodle page.
21 }
23 /**
24  * Include those library functions that are also used by core Moodle or other modules
25  */
26 require_once($CFG->dirroot . '/mod/quiz/lib.php');
27 require_once($CFG->dirroot . '/mod/quiz/accessrules.php');
28 require_once($CFG->dirroot . '/question/editlib.php');
29 require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
30 require_once($CFG->libdir  . '/eventslib.php');
32 /// Constants ///////////////////////////////////////////////////////////////////
34 /**#@+
35  * Constants to describe the various states a quiz attempt can be in.
36  */
37 define('QUIZ_STATE_DURING', 'during');
38 define('QUIZ_STATE_IMMEDIATELY', 'immedately');
39 define('QUIZ_STATE_OPEN', 'open');
40 define('QUIZ_STATE_CLOSED', 'closed');
41 define('QUIZ_STATE_TEACHERACCESS', 'teacheraccess'); // State only relevant if you are in a studenty role.
42 /**#@-*/
44 /**
45  * We don't log every single hit on attempt.php, only significant ones like starting and
46  * ending an attempt, and periodically during the attempt, as defined by this constant. (10 mins)
47  */
48 define('QUIZ_CONTINUE_ATTEMPT_LOG_INTERVAL', '600');
50 /**
51  * We show the countdown timer if there is less than this amount of time left before the
52  * the quiz close date. (1 hour)
53  */
54 define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600');
56 /// Functions related to attempts /////////////////////////////////////////
58 /**
59  * Creates an object to represent a new attempt at a quiz
60  *
61  * Creates an attempt object to represent an attempt at the quiz by the current
62  * user starting at the current time. The ->id field is not set. The object is
63  * NOT written to the database.
64  *
65  * @param object $quiz the quiz to create an attempt for.
66  * @param integer $attemptnumber the sequence number for the attempt.
67  * @param object $lastattempt the previous attempt by this user, if any. Only needed
68  *         if $attemptnumber > 1 and $quiz->attemptonlast is true.
69  * @param integer $timenow the time the attempt was started at.
70  * @param boolean $ispreview whether this new attempt is a preview.
71  *
72  * @return object the newly created attempt object.
73  */
74 function quiz_create_attempt($quiz, $attemptnumber, $lastattempt, $timenow, $ispreview = false) {
75     global $USER;
77     if ($attemptnumber == 1 || !$quiz->attemptonlast) {
78     /// We are not building on last attempt so create a new attempt.
79         $attempt = new stdClass;
80         $attempt->quiz = $quiz->id;
81         $attempt->userid = $USER->id;
82         $attempt->preview = 0;
83         if ($quiz->shufflequestions) {
84             $attempt->layout = quiz_repaginate($quiz->questions, $quiz->questionsperpage, true);
85         } else {
86             $attempt->layout = $quiz->questions;
87         }
88     } else {
89     /// Build on last attempt.
90         if (empty($lastattempt)) {
91             print_error('cannotfindprevattempt', 'quiz');
92         }
93         $attempt = $lastattempt;
94     }
96     $attempt->attempt = $attemptnumber;
97     $attempt->sumgrades = 0.0;
98     $attempt->timestart = $timenow;
99     $attempt->timefinish = 0;
100     $attempt->timemodified = $timenow;
101     $attempt->uniqueid = question_new_attempt_uniqueid();
103 /// If this is a preview, mark it as such.
104     if ($ispreview) {
105         $attempt->preview = 1;
106     }
108     return $attempt;
111 /**
112  * Returns the unfinished attempt for the given
113  * user on the given quiz, if there is one.
114  *
115  * @param integer $quizid the id of the quiz.
116  * @param integer $userid the id of the user.
117  *
118  * @return mixed the unfinished attempt if there is one, false if not.
119  */
120 function quiz_get_user_attempt_unfinished($quizid, $userid) {
121     $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true);
122     if ($attempts) {
123         return array_shift($attempts);
124     } else {
125         return false;
126     }
129 /**
130  * Returns the most recent attempt by a given user on a given quiz.
131  * May be finished, or may not.
132  *
133  * @param integer $quizid the id of the quiz.
134  * @param integer $userid the id of the user.
135  *
136  * @return mixed the attempt if there is one, false if not.
137  */
138 function quiz_get_latest_attempt_by_user($quizid, $userid) {
139     global $CFG, $DB;
140     $attempt = $DB->get_records_sql('SELECT qa.* FROM {quiz_attempts} qa
141             WHERE qa.quiz=? AND qa.userid= ? ORDER BY qa.timestart DESC, qa.id DESC', array($quizid, $userid), 0, 1);
142     if ($attempt) {
143         return array_shift($attempt);
144     } else {
145         return false;
146     }
149 /**
150  * Load an attempt by id. You need to use this method instead of $DB->get_record, because
151  * of some ancient history to do with the upgrade from Moodle 1.4 to 1.5, See the comment
152  * after CREATE TABLE `prefix_quiz_newest_states` in mod/quiz/db/mysql.php.
153  *
154  * @param integer $attemptid the id of the attempt to load.
155  */
156 function quiz_load_attempt($attemptid) {
157     global $DB;
158     $attempt = $DB->get_record('quiz_attempts', array('id' => $attemptid));
160     if (!$DB->record_exists('question_sessions', array('attemptid' => $attempt->uniqueid))) {
161     /// this attempt has not yet been upgraded to the new model
162         quiz_upgrade_states($attempt);
163     }
165     return $attempt;
168 /**
169  * Delete a quiz attempt.
170  * @param mixed $attempt an integer attempt id or an attempt object (row of the quiz_attempts table).
171  * @param object $quiz the quiz object.
172  */
173 function quiz_delete_attempt($attempt, $quiz) {
174     global $DB;
175     if (is_numeric($attempt)) {
176         if (!$attempt = $DB->get_record('quiz_attempts', array('id' => $attempt))) {
177             return;
178         }
179     }
181     if ($attempt->quiz != $quiz->id) {
182         debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " .
183                 "but was passed quiz $quiz->id.");
184         return;
185     }
187     $DB->delete_records('quiz_attempts', array('id' => $attempt->id));
188     delete_attempt($attempt->uniqueid);
190     // Search quiz_attempts for other instances by this user.
191     // If none, then delete record for this quiz, this user from quiz_grades
192     // else recalculate best grade
194     $userid = $attempt->userid;
195     if (!$DB->record_exists('quiz_attempts', array('userid' => $userid, 'quiz' => $quiz->id))) {
196         $DB->delete_records('quiz_grades', array('userid' => $userid,'quiz' => $quiz->id));
197     } else {
198         quiz_save_best_grade($quiz, $userid);
199     }
201     quiz_update_grades($quiz, $userid);
204 /// Functions to do with quiz layout and pages ////////////////////////////////
206 /**
207  * Returns a comma separated list of question ids for the current page
208  *
209  * @return string         Comma separated list of question ids
210  * @param string $layout  The string representing the quiz layout. Each page is represented as a
211  *                        comma separated list of question ids and 0 indicating page breaks.
212  *                        So 5,2,0,3,0 means questions 5 and 2 on page 1 and question 3 on page 2
213  * @param integer $page   The number of the current page.
214  */
215 function quiz_questions_on_page($layout, $page) {
216     $pages = explode(',0', $layout);
217     return trim($pages[$page], ',');
220 /**
221  * Returns a comma separated list of question ids for the quiz
222  *
223  * @return string         Comma separated list of question ids
224  * @param string $layout  The string representing the quiz layout. Each page is represented as a
225  *                        comma separated list of question ids and 0 indicating page breaks.
226  *                        So 5,2,0,3,0 means questions 5 and 2 on page 1 and question 3 on page 2
227  */
228 function quiz_questions_in_quiz($layout) {
229     return str_replace(',0', '', $layout);
232 /**
233  * Returns the number of pages in the quiz layout
234  *
235  * @return integer         Comma separated list of question ids
236  * @param string $layout  The string representing the quiz layout.
237  */
238 function quiz_number_of_pages($layout) {
239     return substr_count($layout, ',0');
242 /**
243  * Returns the first question number for the current quiz page
244  *
245  * @return integer  The number of the first question
246  * @param string $quizlayout The string representing the layout for the whole quiz
247  * @param string $pagelayout The string representing the layout for the current page
248  */
249 function quiz_first_questionnumber($quizlayout, $pagelayout) {
250     // this works by finding all the questions from the quizlayout that
251     // come before the current page and then adding up their lengths.
252     global $CFG, $DB;
253     $start = strpos($quizlayout, ','.$pagelayout.',')-2;
254     if ($start > 0) {
255         $prevlist = substr($quizlayout, 0, $start);
256         list($usql, $params) = $DB->get_in_or_equal(explode(',', $prevlist));
257         return $DB->get_field_sql("SELECT sum(length)+1 FROM {question}
258          WHERE id $usql", $params);
259     } else {
260         return 1;
261     }
264 /**
265  * Re-paginates the quiz layout
266  *
267  * @return string         The new layout string
268  * @param string $layout  The string representing the quiz layout.
269  * @param integer $perpage The number of questions per page
270  * @param boolean $shuffle Should the questions be reordered randomly?
271  */
272 function quiz_repaginate($layout, $perpage, $shuffle=false) {
273     $layout = str_replace(',0', '', $layout); // remove existing page breaks
274     $questions = explode(',', $layout);
275     if ($shuffle) {
276         srand((float)microtime() * 1000000); // for php < 4.2
277         shuffle($questions);
278     }
279     $i = 1;
280     $layout = '';
281     foreach ($questions as $question) {
282         if ($perpage and $i > $perpage) {
283             $layout .= '0,';
284             $i = 1;
285         }
286         $layout .= $question.',';
287         $i++;
288     }
289     return $layout.'0';
292 /**
293  * Print navigation panel for quiz attempt and review pages
294  *
295  * @param integer $page     The number of the current page (counting from 0).
296  * @param integer $pages    The total number of pages.
297  */
298 function quiz_print_navigation_panel($page, $pages) {
299     //$page++;
300     echo '<div class="paging pagingbar">';
301     echo '<span class="title">' . get_string('page') . ':</span>';
302     if ($page > 0) {
303         // Print previous link
304         $strprev = get_string('previous');
305         echo '<a href="javascript:navigate(' . ($page - 1) . ');" title="'
306          . $strprev . '">(' . $strprev . ')</a>';
307     }
308     for ($i = 0; $i < $pages; $i++) {
309         if ($i == $page) {
310             echo '<span class="thispage">'.($i+1).'</span>';
311         } else {
312             echo '<a href="javascript:navigate(' . ($i) . ');">'.($i+1).'</a>';
313         }
314     }
316     if ($page < $pages - 1) {
317         // Print next link
318         $strnext = get_string('next');
319         echo '<a href="javascript:navigate(' . ($page + 1) . ');" title="'
320          . $strnext . '">(' . $strnext . ')</a>';
321     }
322     echo '</div>';
325 /// Functions to do with quiz grades //////////////////////////////////////////
327 /**
328  * Creates an array of maximum grades for a quiz
329  *
330  * The grades are extracted from the quiz_question_instances table.
331  * @return array        Array of grades indexed by question id
332  *                      These are the maximum possible grades that
333  *                      students can achieve for each of the questions
334  * @param integer $quiz The quiz object
335  */
336 function quiz_get_all_question_grades($quiz) {
337     global $CFG, $DB;
339     $questionlist = quiz_questions_in_quiz($quiz->questions);
340     if (empty($questionlist)) {
341         return array();
342     }
344     $params = array($quiz->id);
345     $wheresql = '';
346     if (!is_null($questionlist)) {
347         list($usql, $question_params) = $DB->get_in_or_equal(explode(',', $questionlist));
348         $wheresql = " AND question $usql ";
349         $params = array_merge($params, $question_params);
350     }
352     $instances = $DB->get_records_sql("SELECT question,grade,id
353                                     FROM {quiz_question_instances}
354                                     WHERE quiz = ? $wheresql", $params);
356     $list = explode(",", $questionlist);
357     $grades = array();
359     foreach ($list as $qid) {
360         if (isset($instances[$qid])) {
361             $grades[$qid] = $instances[$qid]->grade;
362         } else {
363             $grades[$qid] = 1;
364         }
365     }
366     return $grades;
369 /**
370  * Get the best current grade for a particular user in a quiz.
371  *
372  * @param object $quiz the quiz object.
373  * @param integer $userid the id of the user.
374  * @return float the user's current grade for this quiz, or NULL if this user does
375  * not have a grade on this quiz.
376  */
377 function quiz_get_best_grade($quiz, $userid) {
378     global $DB;
379     $grade = $DB->get_field('quiz_grades', 'grade', array('quiz' => $quiz->id, 'userid' => $userid));
381     // Need to detect errors/no result, without catching 0 scores.
382     if (is_numeric($grade)) {
383         return quiz_format_grade($quiz, $grade);
384     } else {
385         return NULL;
386     }
389 /**
390  * Convert the raw grade stored in $attempt into a grade out of the maximum
391  * grade for this quiz.
392  *
393  * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades
394  * @param object $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used.
395  * @return float the rescaled grade.
396  */
397 function quiz_rescale_grade($rawgrade, $quiz, $round = true) {
398     if ($quiz->sumgrades) {
399         $grade = $rawgrade * $quiz->grade / $quiz->sumgrades;
400         if ($round) {
401             $grade = quiz_format_grade($quiz, $grade);
402         }
403     } else {
404         $grade = 0;
405     }
406     return $grade;
409 /**
410  * Get the feedback text that should be show to a student who
411  * got this grade on this quiz. The feedback is processed ready for diplay.
412  *
413  * @param float $grade a grade on this quiz.
414  * @param integer $quizid the id of the quiz object.
415  * @return string the comment that corresponds to this grade (empty string if there is not one.
416  */
417 function quiz_feedback_for_grade($grade, $quizid) {
418     global $DB;
419     $feedback = $DB->get_field_select('quiz_feedback', 'feedbacktext',
420             "quizid = ? AND mingrade <= ? AND $grade < maxgrade", array($quizid, $grade));
422     if (empty($feedback)) {
423         $feedback = '';
424     }
426     // Clean the text, ready for display.
427     $formatoptions = new stdClass;
428     $formatoptions->noclean = true;
429     $feedback = format_text($feedback, FORMAT_MOODLE, $formatoptions);
431     return $feedback;
434 /**
435  * @param integer $quizid the id of the quiz object.
436  * @return boolean Whether this quiz has any non-blank feedback text.
437  */
438 function quiz_has_feedback($quizid) {
439     global $DB;
440     static $cache = array();
441     if (!array_key_exists($quizid, $cache)) {
442         $cache[$quizid] = $DB->record_exists_select('quiz_feedback',
443                 "quizid = ? AND " . $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true), array($quizid));
444     }
445     return $cache[$quizid];
448 /**
449  * The quiz grade is the score that student's results are marked out of. When it
450  * changes, the corresponding data in quiz_grades and quiz_feedback needs to be
451  * rescaled.
452  *
453  * @param float $newgrade the new maximum grade for the quiz.
454  * @param object $quiz the quiz we are updating. Passed by reference so its grade field can be updated too.
455  * @return boolean indicating success or failure.
456  */
457 function quiz_set_grade($newgrade, &$quiz) {
458     global $DB;
459     // This is potentially expensive, so only do it if necessary.
460     if (abs($quiz->grade - $newgrade) < 1e-7) {
461         // Nothing to do.
462         return true;
463     }
465     // Use a transaction, so that on those databases that support it, this is safer.
466     $DB->begin_sql();
468     // Update the quiz table.
469     $success = $DB->set_field('quiz', 'grade', $newgrade, array('id' => $quiz->instance));
471     // Rescaling the other data is only possible if the old grade was non-zero.
472     if ($quiz->grade > 1e-7) {
473         global $CFG;
475         $factor = $newgrade/$quiz->grade;
476         $quiz->grade = $newgrade;
478         // Update the quiz_grades table.
479         $timemodified = time();
480         $success = $success && $DB->execute("
481                 UPDATE {quiz_grades}
482                 SET grade = ? * grade, timemodified = ?
483                 WHERE quiz = ?
484         ", array($factor, $timemodified, $quiz->id));
486         // Update the quiz_grades table.
487         $success = $success && $DB->execute("
488                 UPDATE {quiz_feedback}
489                 SET mingrade = ? * mingrade, maxgrade = ? * maxgrade
490                 WHERE quizid = ?
491         ", array($factor, $factor, $quiz->id));
492     }
494     // update grade item and send all grades to gradebook
495     quiz_grade_item_update($quiz);
496     quiz_update_grades($quiz);
498     if ($success) {
499         return $DB->commit_sql();
500     } else {
501         $DB->rollback_sql();
502         return false;
503     }
506 /**
507  * Save the overall grade for a user at a quiz in the quiz_grades table
508  *
509  * @param object $quiz The quiz for which the best grade is to be calculated and then saved.
510  * @param integer $userid The userid to calculate the grade for. Defaults to the current user.
511  * @param array $attempts The attempts of this user. Useful if you are
512  * looping through many users. Attempts can be fetched in one master query to
513  * avoid repeated querying.
514  * @return boolean Indicates success or failure.
515  */
516 function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) {
517     global $DB;
518     global $USER;
520     if (empty($userid)) {
521         $userid = $USER->id;
522     }
524     if (!$attempts){
525         // Get all the attempts made by the user
526         if (!$attempts = quiz_get_user_attempts($quiz->id, $userid)) {
527             notify('Could not find any user attempts');
528             return false;
529         }
530     }
532     // Calculate the best grade
533     $bestgrade = quiz_calculate_best_grade($quiz, $attempts);
534     $bestgrade = quiz_rescale_grade($bestgrade, $quiz);
536     // Save the best grade in the database
537     if ($grade = $DB->get_record('quiz_grades', array('quiz' => $quiz->id, 'userid' => $userid))) {
538         $grade->grade = $bestgrade;
539         $grade->timemodified = time();
540         if (!$DB->update_record('quiz_grades', $grade)) {
541             notify('Could not update best grade');
542             return false;
543         }
544     } else {
545         $grade->quiz = $quiz->id;
546         $grade->userid = $userid;
547         $grade->grade = $bestgrade;
548         $grade->timemodified = time();
549         if (!$DB->insert_record('quiz_grades', $grade)) {
550             notify('Could not insert new best grade');
551             return false;
552         }
553     }
555     quiz_update_grades($quiz, $userid);
556     return true;
559 /**
560  * Calculate the overall grade for a quiz given a number of attempts by a particular user.
561  *
562  * @return float          The overall grade
563  * @param object $quiz    The quiz for which the best grade is to be calculated
564  * @param array $attempts An array of all the attempts of the user at the quiz
565  */
566 function quiz_calculate_best_grade($quiz, $attempts) {
568     switch ($quiz->grademethod) {
570         case QUIZ_ATTEMPTFIRST:
571             foreach ($attempts as $attempt) {
572                 return $attempt->sumgrades;
573             }
574             break;
576         case QUIZ_ATTEMPTLAST:
577             foreach ($attempts as $attempt) {
578                 $final = $attempt->sumgrades;
579             }
580             return $final;
582         case QUIZ_GRADEAVERAGE:
583             $sum = 0;
584             $count = 0;
585             foreach ($attempts as $attempt) {
586                 $sum += $attempt->sumgrades;
587                 $count++;
588             }
589             return (float)$sum/$count;
591         default:
592         case QUIZ_GRADEHIGHEST:
593             $max = 0;
594             foreach ($attempts as $attempt) {
595                 if ($attempt->sumgrades > $max) {
596                     $max = $attempt->sumgrades;
597                 }
598             }
599             return $max;
600     }
603 /**
604  * Return the attempt with the best grade for a quiz
605  *
606  * Which attempt is the best depends on $quiz->grademethod. If the grade
607  * method is GRADEAVERAGE then this function simply returns the last attempt.
608  * @return object         The attempt with the best grade
609  * @param object $quiz    The quiz for which the best grade is to be calculated
610  * @param array $attempts An array of all the attempts of the user at the quiz
611  */
612 function quiz_calculate_best_attempt($quiz, $attempts) {
614     switch ($quiz->grademethod) {
616         case QUIZ_ATTEMPTFIRST:
617             foreach ($attempts as $attempt) {
618                 return $attempt;
619             }
620             break;
622         case QUIZ_GRADEAVERAGE: // need to do something with it :-)
623         case QUIZ_ATTEMPTLAST:
624             foreach ($attempts as $attempt) {
625                 $final = $attempt;
626             }
627             return $final;
629         default:
630         case QUIZ_GRADEHIGHEST:
631             $max = -1;
632             foreach ($attempts as $attempt) {
633                 if ($attempt->sumgrades > $max) {
634                     $max = $attempt->sumgrades;
635                     $maxattempt = $attempt;
636                 }
637             }
638             return $maxattempt;
639     }
642 /**
643  * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST.
644  * @return the lang string for that option.
645  */
646 function quiz_get_grading_option_name($option) {
647     $strings = quiz_get_grading_options();
648     return $strings[$option];
651 /// Other quiz functions ////////////////////////////////////////////////////
653 /**
654  * Parse field names used for the replace options on question edit forms
655  */
656 function quiz_parse_fieldname($name, $nameprefix='question') {
657     $reg = array();
658     if (preg_match("/$nameprefix(\\d+)(\w+)/", $name, $reg)) {
659         return array('mode' => $reg[2], 'id' => (int)$reg[1]);
660     } else {
661         return false;
662     }
665 /**
666  * Upgrade states for an attempt to Moodle 1.5 model
667  *
668  * Any state that does not yet have its timestamp set to nonzero has not yet been upgraded from Moodle 1.4
669  * The reason these are still around is that for large sites it would have taken too long to
670  * upgrade all states at once. This function sets the timestamp field and creates an entry in the
671  * question_sessions table.
672  * @param object $attempt  The attempt whose states need upgrading
673  */
674 function quiz_upgrade_states($attempt) {
675     global $DB;
676     global $CFG;
677     // The old quiz model only allowed a single response per quiz attempt so that there will be
678     // only one state record per question for this attempt.
680     // We set the timestamp of all states to the timemodified field of the attempt.
681     $DB->execute("UPDATE {question_states} SET timestamp = ? WHERE attempt = ?", array($attempt->timemodified, $attempt->uniqueid));
683     // For each state we create an entry in the question_sessions table, with both newest and
684     // newgraded pointing to this state.
685     // Actually we only do this for states whose question is actually listed in $attempt->layout.
686     // We do not do it for states associated to wrapped questions like for example the questions
687     // used by a RANDOM question
688     $session = new stdClass;
689     $session->attemptid = $attempt->uniqueid;
690     $questionlist = quiz_questions_in_quiz($attempt->layout);
691     $params = array($attempt->uniqueid);
692     list($usql, $question_params) = $DB->get_in_or_equal(explode(',',$questionlist));
693     $params = array_merge($params, $question_params);
695     if ($questionlist and $states = $DB->get_records_select('question_states', "attempt = ? AND question $usql", $params)) {
696         foreach ($states as $state) {
697             $session->newgraded = $state->id;
698             $session->newest = $state->id;
699             $session->questionid = $state->question;
700             $DB->insert_record('question_sessions', $session, false);
701         }
702     }
704 /**
705  * Function that can be used in various parts of the quiz code.
706  * @param object $quiz
707  * @param integer $cmid
708  * @param object $question
709  * @param string $returnurl url to return to after action is done.
710  * @return string html for a number of icons linked to action pages for a
711  * question - preview and edit / view icons depending on user capabilities.
712  */
713 function quiz_question_action_icons($quiz, $cmid, $question, $returnurl){
714     global $CFG;
715     static $stredit = null;
716     static $strview = null;
717     if ($stredit === null){
718         $stredit = get_string('edit');
719         $strview = get_string('view');
720     }
721     $html =''; 
722     if (($question->qtype != 'random')){
723         $html .= quiz_question_preview_button($quiz, $question);
724     }
725     $questionparams = array('returnurl' => $returnurl, 'cmid'=>$cmid, 'id' => $question->id);
726     $questionurl = new moodle_url("$CFG->wwwroot/question/question.php", $questionparams);
727     if (question_has_capability_on($question, 'edit', $question->category) || question_has_capability_on($question, 'move', $question->category)) {
728         $html .= "<a title=\"$stredit\" href=\"".$questionurl->out()."\">
729                 <img src=\"$CFG->pixpath/t/edit.gif\" class=\"iconsmall\" alt=\"$stredit\" /></a>";
730     } elseif (question_has_capability_on($question, 'view', $question->category)){
731         $html .= "<a title=\"$strview\" href=\"".$questionurl->out(false, array('id'=>$question->id))."\"><img
732                 src=\"$CFG->pixpath/i/info.gif\" alt=\"$strview\" /></a>&nbsp;";
733     }
734     return $html;
738 /**
739  * @param object $quiz the quiz
740  * @param object $question the question
741  * @return the HTML for a preview question icon.
742  */
743 function quiz_question_preview_button($quiz, $question) {
744     global $CFG, $COURSE;
745     if (!question_has_capability_on($question, 'use', $question->category)){
746         return '';
747     }
748     $strpreview = get_string('previewquestion', 'quiz');
749     $quizorcourseid = $quiz->id?('&amp;quizid=' . $quiz->id):('&amp;courseid=' .$COURSE->id);
750     return link_to_popup_window('/question/preview.php?id=' . $question->id . $quizorcourseid, 'questionpreview',
751             "<img src=\"$CFG->pixpath/t/preview.gif\" class=\"iconsmall\" alt=\"$strpreview\" />",
752             0, 0, $strpreview, QUESTION_PREVIEW_POPUP_OPTIONS, true);
755 /**
756  * @param object $attempt the attempt.
757  * @param object $context the quiz context.
758  * @return integer whether flags should be shown/editable to the current user for this attempt.
759  */
760 function quiz_get_flag_option($attempt, $context) {
761     global $USER;
762     static $flagmode = null;
763     if (is_null($flagmode)) {
764         if (!has_capability('moodle/question:flag', $context)) {
765             $flagmode = QUESTION_FLAGSHIDDEN;
766         } else if ($attempt->userid == $USER->id) {
767             $flagmode = QUESTION_FLAGSEDITABLE;
768         } else {
769             $flagmode = QUESTION_FLAGSSHOWN;
770         }
771     }
772     return $flagmode;
775 /**
776  * Determine render options
777  *
778  * @param int $reviewoptions
779  * @param object $state
780  */
781 function quiz_get_renderoptions($quiz, $attempt, $context, $state) {
782     $reviewoptions = $quiz->review;
783     $options = new stdClass;
785     $options->flags = quiz_get_flag_option($attempt, $context);
787     // Show the question in readonly (review) mode if the question is in
788     // the closed state
789     $options->readonly = question_state_is_closed($state);
791     // Show feedback once the question has been graded (if allowed by the quiz)
792     $options->feedback = question_state_is_graded($state) && ($reviewoptions & QUIZ_REVIEW_FEEDBACK & QUIZ_REVIEW_IMMEDIATELY);
794     // Show correct responses in readonly mode if the quiz allows it
795     $options->correct_responses = $options->readonly && ($reviewoptions & QUIZ_REVIEW_ANSWERS & QUIZ_REVIEW_IMMEDIATELY);
797     // Show general feedback if the question has been graded and the quiz allows it.
798     $options->generalfeedback = question_state_is_graded($state) && ($reviewoptions & QUIZ_REVIEW_GENERALFEEDBACK & QUIZ_REVIEW_IMMEDIATELY);
800     // Show overallfeedback once the attempt is over.
801     $options->overallfeedback = false;
803     // Always show responses and scores
804     $options->responses = true;
805     $options->scores = true;
806     $options->quizstate = QUIZ_STATE_DURING;
807     $options->history = false;
809     return $options;
812 /**
813  * Determine review options
814  *
815  * @param object $quiz the quiz instance.
816  * @param object $attempt the attempt in question.
817  * @param $context the quiz module context.
818  *
819  * @return object an object with boolean fields responses, scores, feedback,
820  *          correct_responses, solutions and general feedback
821  */
822 function quiz_get_reviewoptions($quiz, $attempt, $context) {
823     global $USER;
825     $options = new stdClass;
826     $options->readonly = true;
828     $options->flags = quiz_get_flag_option($attempt, $context);
830     // Provide the links to the question review and comment script
831     if (!empty($attempt->id)) {
832         $options->questionreviewlink = '/mod/quiz/reviewquestion.php?attempt=' . $attempt->id;
833     }
835     // Show a link to the comment box only for closed attempts
836     if ($attempt->timefinish && has_capability('mod/quiz:grade', $context)) {
837         $options->questioncommentlink = '/mod/quiz/comment.php';
838     }
840     // Whether to display a response history.
841     $canviewreports = has_capability('mod/quiz:viewreports', $context);
842     $options->history = ($canviewreports && !$attempt->preview) ? 'all' : 'graded';
844     if ($canviewreports && has_capability('moodle/grade:viewhidden', $context) && !$attempt->preview) {
845         // People who can see reports and hidden grades should be shown everything,
846         // except during preview when teachers want to see what students see.
847         $options->responses = true;
848         $options->scores = true;
849         $options->feedback = true;
850         $options->correct_responses = true;
851         $options->solutions = false;
852         $options->generalfeedback = true;
853         $options->overallfeedback = true;
854         $options->quizstate = QUIZ_STATE_TEACHERACCESS;
855     } else {
856         // Work out the state of the attempt ...
857         if (((time() - $attempt->timefinish) < 120) || $attempt->timefinish==0) {
858             $quiz_state_mask = QUIZ_REVIEW_IMMEDIATELY;
859             $options->quizstate = QUIZ_STATE_IMMEDIATELY;
860         } else if (!$quiz->timeclose or time() < $quiz->timeclose) {
861             $quiz_state_mask = QUIZ_REVIEW_OPEN;
862             $options->quizstate = QUIZ_STATE_OPEN;
863         } else {
864             $quiz_state_mask = QUIZ_REVIEW_CLOSED;
865             $options->quizstate = QUIZ_STATE_CLOSED;
866         }
868         // ... and hence extract the appropriate review options.
869         $options->responses = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_RESPONSES) ? 1 : 0;
870         $options->scores = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_SCORES) ? 1 : 0;
871         $options->feedback = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_FEEDBACK) ? 1 : 0;
872         $options->correct_responses = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_ANSWERS) ? 1 : 0;
873         $options->solutions = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_SOLUTIONS) ? 1 : 0;
874         $options->generalfeedback = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_GENERALFEEDBACK) ? 1 : 0;
875         $options->overallfeedback = $attempt->timefinish && ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_OVERALLFEEDBACK);
876     }
878     return $options;
881 /**
882  * Combines the review options from a number of different quiz attempts.
883  * Returns an array of two ojects, so he suggested way of calling this
884  * funciton is:
885  * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...)
886  *
887  * @param object $quiz the quiz instance.
888  * @param array $attempts an array of attempt objects.
889  * @param $context the roles and permissions context,
890  *          normally the context for the quiz module instance.
891  *
892  * @return array of two options objects, one showing which options are true for
893  *          at least one of the attempts, the other showing which options are true
894  *          for all attempts.
895  */
896 function quiz_get_combined_reviewoptions($quiz, $attempts, $context) {
897     $fields = array('readonly', 'scores', 'feedback', 'correct_responses', 'solutions', 'generalfeedback', 'overallfeedback');
898     $someoptions = new stdClass;
899     $alloptions = new stdClass;
900     foreach ($fields as $field) {
901         $someoptions->$field = false;
902         $alloptions->$field = true;
903     }
904     foreach ($attempts as $attempt) {
905         $attemptoptions = quiz_get_reviewoptions($quiz, $attempt, $context);
906         foreach ($fields as $field) {
907             $someoptions->$field = $someoptions->$field || $attemptoptions->$field;
908             $alloptions->$field = $alloptions->$field && $attemptoptions->$field;
909         }
910     }
911     return array($someoptions, $alloptions);
914 /// FUNCTIONS FOR SENDING NOTIFICATION EMAILS ///////////////////////////////
916 /**
917  * Sends confirmation email to the student taking the course
918  *
919  * @param stdClass $a associative array of replaceable fields for the templates
920  *
921  * @return bool|string result of events_triger
922  */
923 function quiz_send_confirmation($a) {
925     global $USER;
927     // recipient is self
928     $a->useridnumber = $USER->idnumber;
929     $a->username = fullname($USER);
930     $a->userusername = $USER->username;
932     // fetch the subject and body from strings
933     $subject = get_string('emailconfirmsubject', 'quiz', $a);
934     $body = get_string('emailconfirmbody', 'quiz', $a);
936     // send email and analyse result
937     $eventdata = new object();
938     $eventdata->modulename        = 'quiz';
939     $eventdata->userfrom          = get_admin();
940     $eventdata->userto            = $USER;
941     $eventdata->subject           = $subject;
942     $eventdata->fullmessage       = $body;
943     $eventdata->fullmessageformat = FORMAT_PLAIN;
944     $eventdata->fullmessagehtml   = '';
945     $eventdata->smallmessage      = '';
946     return (events_trigger('message_send', $eventdata) == 0);
949 /**
950  * Sends notification email to the interested parties that assign the role capability
951  *
952  * @param object $recipient user object of the intended recipient
953  * @param stdClass $a associative array of replaceable fields for the templates
954  *
955  * @return bool|string result of events_triger()
956  */
957 function quiz_send_notification($recipient, $a) {
959     global $USER;
961     // recipient info for template
962     $a->username = fullname($recipient);
963     $a->userusername = $recipient->username;
964     $a->userusername = $recipient->username;
966     // fetch the subject and body from strings
967     $subject = get_string('emailnotifysubject', 'quiz', $a);
968     $body = get_string('emailnotifybody', 'quiz', $a);
970     // send email and analyse result
971     $eventdata = new object();
972     $eventdata->modulename        = 'quiz';
973     $eventdata->userfrom          = $USER;
974     $eventdata->userto            = $recipient;
975     $eventdata->subject           = $subject;
976     $eventdata->fullmessage       = $body;
977     $eventdata->fullmessageformat = FORMAT_PLAIN;
978     $eventdata->fullmessagehtml   = '';
979     $eventdata->smallmessage      = '';
980     return (events_trigger('message_send', $eventdata) == 0);
983 /**
984  * Takes a bunch of information to format into an email and send
985  * to the specified recipient.
986  *
987  * @param object $course the course
988  * @param object $quiz the quiz
989  * @param object $attempt this attempt just finished
990  * @param object $context the quiz context
991  * @param object $cm the coursemodule for this quiz
992  *
993  * @return int number of emails sent
994  */
995 function quiz_send_notification_emails($course, $quiz, $attempt, $context, $cm) {
996     global $CFG, $USER;
997     // we will count goods and bads for error logging
998     $emailresult = array('good' => 0, 'block' => 0, 'fail' => 0);
1000     // do nothing if required objects not present
1001     if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1002         debugging('quiz_send_notification_emails: Email(s) not sent due to program error.',
1003                 DEBUG_DEVELOPER);
1004         return $emailresult['fail'];
1005     }
1007     // check for confirmation required
1008     $sendconfirm = false;
1009     $notifyexcludeusers = '';
1010     if (has_capability('mod/quiz:emailconfirmsubmission', $context, NULL, false)) {
1011         // exclude from notify emails later
1012         $notifyexcludeusers = $USER->id;
1013         // send the email
1014         $sendconfirm = true;
1015     }
1017     // check for notifications required
1018     $notifyfields = 'u.id, u.username, u.firstname, u.lastname, u.email, u.emailstop, u.lang, u.timezone, u.mailformat, u.maildisplay';
1019     $groups = groups_get_all_groups($course->id, $USER->id);
1020     if (is_array($groups) && count($groups) > 0) {
1021         $groups = array_keys($groups);
1022     } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) {
1023         // If the user is not in a group, and the quiz is set to group mode,
1024         // then set $gropus to a non-existant id so that only users with
1025         // 'moodle/site:accessallgroups' get notified.
1026         $groups = -1;
1027     } else {
1028         $groups = '';
1029     }
1030     $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission',
1031             $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true);
1033     // if something to send, then build $a
1034     if (! empty($userstonotify) or $sendconfirm) {
1035         $a = new stdClass;
1036         // course info
1037         $a->coursename = $course->fullname;
1038         $a->courseshortname = $course->shortname;
1039         // quiz info
1040         $a->quizname = $quiz->name;
1041         $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?q=' . $quiz->id;
1042         $a->quizreportlink = '<a href="' . $a->quizreporturl . '">' . format_string($quiz->name) . ' report</a>';
1043         $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id;
1044         $a->quizreviewlink = '<a href="' . $a->quizreviewurl . '">' . format_string($quiz->name) . ' review</a>';
1045         $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?q=' . $quiz->id;
1046         $a->quizlink = '<a href="' . $a->quizurl . '">' . format_string($quiz->name) . '</a>';
1047         // attempt info
1048         $a->submissiontime = userdate($attempt->timefinish);
1049         $a->timetaken = format_time($attempt->timefinish - $attempt->timestart);
1050         // student who sat the quiz info
1051         $a->studentidnumber = $USER->idnumber;
1052         $a->studentname = fullname($USER);
1053         $a->studentusername = $USER->username;
1054     }
1056     // send confirmation if required
1057     if ($sendconfirm) {
1058         // send the email and update stats
1059         switch (quiz_send_confirmation($a)) {
1060             case true:
1061                 $emailresult['good']++;
1062                 break;
1063             case false:
1064                 $emailresult['fail']++;
1065                 break;
1066             case 'emailstop':
1067                 $emailresult['block']++;
1068                 break;
1069         }
1070     }
1072     // send notifications if required
1073     if (!empty($userstonotify)) {
1074         // loop through recipients and send an email to each and update stats
1075         foreach ($userstonotify as $recipient) {
1076             switch (quiz_send_notification($recipient, $a)) {
1077                 case true:
1078                     $emailresult['good']++;
1079                     break;
1080                 case false:
1081                     $emailresult['fail']++;
1082                     break;
1083                 case 'emailstop':
1084                     $emailresult['block']++;
1085                     break;
1086             }
1087         }
1088     }
1090     // log errors sending emails if any
1091     if (! empty($emailresult['fail'])) {
1092         debugging('quiz_send_notification_emails:: '.$emailresult['fail'].' email(s) failed to be sent.', DEBUG_DEVELOPER);
1093     }
1094     if (! empty($emailresult['block'])) {
1095         debugging('quiz_send_notification_emails:: '.$emailresult['block'].' email(s) were blocked by the user.', DEBUG_DEVELOPER);
1096     }
1098     // return the number of successfully sent emails
1099     return $emailresult['good'];
1102 /**
1103  * Print a quiz error message. This is a thin wrapper around print_error, for convinience.
1104  *
1105  * @param mixed $quiz either the quiz object, or the interger quiz id.
1106  * @param string $errorcode the name of the string from quiz.php to print.
1107  * @param object $a any extra data required by the error string.
1108  */
1109 function quiz_error($quiz, $errorcode, $a = null) {
1110     global $CFG;
1111     if (is_object($quiz)) {
1112         $quiz = $quiz->id;
1113     }
1114     print_error($errorcode, 'quiz', $CFG->wwwroot . '/mod/quiz/view.php?q=' . $quiz, $a);
1116 ?>