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