1be2baa392990c7259988e59e8084c491294b45f
[moodle.git] / mod / quiz / attemptlib.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Back-end code for handling data about quizzes and the current user's attempt.
19  *
20  * There are classes for loading all the information about a quiz and attempts,
21  * and for displaying the navigation panel.
22  *
23  * @package   mod_quiz
24  * @copyright 2008 onwards Tim Hunt
25  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26  */
29 defined('MOODLE_INTERNAL') || die();
32 /**
33  * Class for quiz exceptions. Just saves a couple of arguments on the
34  * constructor for a moodle_exception.
35  *
36  * @copyright 2008 Tim Hunt
37  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38  * @since     Moodle 2.0
39  */
40 class moodle_quiz_exception extends moodle_exception {
41     public function __construct($quizobj, $errorcode, $a = null, $link = '', $debuginfo = null) {
42         if (!$link) {
43             $link = $quizobj->view_url();
44         }
45         parent::__construct($errorcode, 'quiz', $link, $a, $debuginfo);
46     }
47 }
50 /**
51  * A class encapsulating a quiz and the questions it contains, and making the
52  * information available to scripts like view.php.
53  *
54  * Initially, it only loads a minimal amout of information about each question - loading
55  * extra information only when necessary or when asked. The class tracks which questions
56  * are loaded.
57  *
58  * @copyright  2008 Tim Hunt
59  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
60  * @since      Moodle 2.0
61  */
62 class quiz {
63     // Fields initialised in the constructor.
64     protected $course;
65     protected $cm;
66     protected $quiz;
67     protected $context;
69     // Fields set later if that data is needed.
70     protected $questions = null;
71     protected $accessmanager = null;
72     protected $ispreviewuser = null;
74     // Constructor =============================================================
75     /**
76      * Constructor, assuming we already have the necessary data loaded.
77      *
78      * @param object $quiz the row from the quiz table.
79      * @param object $cm the course_module object for this quiz.
80      * @param object $course the row from the course table for the course we belong to.
81      * @param bool $getcontext intended for testing - stops the constructor getting the context.
82      */
83     public function __construct($quiz, $cm, $course, $getcontext = true) {
84         $this->quiz = $quiz;
85         $this->cm = $cm;
86         $this->quiz->cmid = $this->cm->id;
87         $this->course = $course;
88         if ($getcontext && !empty($cm->id)) {
89             $this->context = context_module::instance($cm->id);
90         }
91     }
93     /**
94      * Static function to create a new quiz object for a specific user.
95      *
96      * @param int $quizid the the quiz id.
97      * @param int $userid the the userid.
98      * @return quiz the new quiz object
99      */
100     public static function create($quizid, $userid = null) {
101         global $DB;
103         $quiz = quiz_access_manager::load_quiz_and_settings($quizid);
104         $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
105         $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST);
107         // Update quiz with override information.
108         if ($userid) {
109             $quiz = quiz_update_effective_access($quiz, $userid);
110         }
112         return new quiz($quiz, $cm, $course);
113     }
115     /**
116      * Create a {@link quiz_attempt} for an attempt at this quiz.
117      * @param object $attemptdata row from the quiz_attempts table.
118      * @return quiz_attempt the new quiz_attempt object.
119      */
120     public function create_attempt_object($attemptdata) {
121         return new quiz_attempt($attemptdata, $this->quiz, $this->cm, $this->course);
122     }
124     // Functions for loading more data =========================================
126     /**
127      * Load just basic information about all the questions in this quiz.
128      */
129     public function preload_questions() {
130         $this->questions = question_preload_questions(null,
131                 'slot.maxmark, slot.id AS slotid, slot.slot, slot.page',
132                 '{quiz_slots} slot ON slot.quizid = :quizid AND q.id = slot.questionid',
133                 array('quizid' => $this->quiz->id), 'slot.slot');
134     }
136     /**
137      * Fully load some or all of the questions for this quiz. You must call
138      * {@link preload_questions()} first.
139      *
140      * @param array $questionids question ids of the questions to load. null for all.
141      */
142     public function load_questions($questionids = null) {
143         if ($this->questions === null) {
144             throw new coding_exception('You must call preload_questions before calling load_questions.');
145         }
146         if (is_null($questionids)) {
147             $questionids = array_keys($this->questions);
148         }
149         $questionstoprocess = array();
150         foreach ($questionids as $id) {
151             if (array_key_exists($id, $this->questions)) {
152                 $questionstoprocess[$id] = $this->questions[$id];
153             }
154         }
155         get_question_options($questionstoprocess);
156     }
158     /**
159      * Get an instance of the {@link \mod_quiz\structure} class for this quiz.
160      * @return \mod_quiz\structure describes the questions in the quiz.
161      */
162     public function get_structure() {
163         return \mod_quiz\structure::create_for_quiz($this);
164     }
166     // Simple getters ==========================================================
167     /** @return int the course id. */
168     public function get_courseid() {
169         return $this->course->id;
170     }
172     /** @return object the row of the course table. */
173     public function get_course() {
174         return $this->course;
175     }
177     /** @return int the quiz id. */
178     public function get_quizid() {
179         return $this->quiz->id;
180     }
182     /** @return object the row of the quiz table. */
183     public function get_quiz() {
184         return $this->quiz;
185     }
187     /** @return string the name of this quiz. */
188     public function get_quiz_name() {
189         return $this->quiz->name;
190     }
192     /** @return int the quiz navigation method. */
193     public function get_navigation_method() {
194         return $this->quiz->navmethod;
195     }
197     /** @return int the number of attempts allowed at this quiz (0 = infinite). */
198     public function get_num_attempts_allowed() {
199         return $this->quiz->attempts;
200     }
202     /** @return int the course_module id. */
203     public function get_cmid() {
204         return $this->cm->id;
205     }
207     /** @return object the course_module object. */
208     public function get_cm() {
209         return $this->cm;
210     }
212     /** @return object the module context for this quiz. */
213     public function get_context() {
214         return $this->context;
215     }
217     /**
218      * @return bool wether the current user is someone who previews the quiz,
219      * rather than attempting it.
220      */
221     public function is_preview_user() {
222         if (is_null($this->ispreviewuser)) {
223             $this->ispreviewuser = has_capability('mod/quiz:preview', $this->context);
224         }
225         return $this->ispreviewuser;
226     }
228     /**
229      * @return whether any questions have been added to this quiz.
230      */
231     public function has_questions() {
232         if ($this->questions === null) {
233             $this->preload_questions();
234         }
235         return !empty($this->questions);
236     }
238     /**
239      * @param int $id the question id.
240      * @return object the question object with that id.
241      */
242     public function get_question($id) {
243         return $this->questions[$id];
244     }
246     /**
247      * @param array $questionids question ids of the questions to load. null for all.
248      */
249     public function get_questions($questionids = null) {
250         if (is_null($questionids)) {
251             $questionids = array_keys($this->questions);
252         }
253         $questions = array();
254         foreach ($questionids as $id) {
255             if (!array_key_exists($id, $this->questions)) {
256                 throw new moodle_exception('cannotstartmissingquestion', 'quiz', $this->view_url());
257             }
258             $questions[$id] = $this->questions[$id];
259             $this->ensure_question_loaded($id);
260         }
261         return $questions;
262     }
264     /**
265      * @param int $timenow the current time as a unix timestamp.
266      * @return quiz_access_manager and instance of the quiz_access_manager class
267      *      for this quiz at this time.
268      */
269     public function get_access_manager($timenow) {
270         if (is_null($this->accessmanager)) {
271             $this->accessmanager = new quiz_access_manager($this, $timenow,
272                     has_capability('mod/quiz:ignoretimelimits', $this->context, null, false));
273         }
274         return $this->accessmanager;
275     }
277     /**
278      * Wrapper round the has_capability funciton that automatically passes in the quiz context.
279      */
280     public function has_capability($capability, $userid = null, $doanything = true) {
281         return has_capability($capability, $this->context, $userid, $doanything);
282     }
284     /**
285      * Wrapper round the require_capability funciton that automatically passes in the quiz context.
286      */
287     public function require_capability($capability, $userid = null, $doanything = true) {
288         return require_capability($capability, $this->context, $userid, $doanything);
289     }
291     // URLs related to this attempt ============================================
292     /**
293      * @return string the URL of this quiz's view page.
294      */
295     public function view_url() {
296         global $CFG;
297         return $CFG->wwwroot . '/mod/quiz/view.php?id=' . $this->cm->id;
298     }
300     /**
301      * @return string the URL of this quiz's edit page.
302      */
303     public function edit_url() {
304         global $CFG;
305         return $CFG->wwwroot . '/mod/quiz/edit.php?cmid=' . $this->cm->id;
306     }
308     /**
309      * @param int $attemptid the id of an attempt.
310      * @param int $page optional page number to go to in the attempt.
311      * @return string the URL of that attempt.
312      */
313     public function attempt_url($attemptid, $page = 0) {
314         global $CFG;
315         $url = $CFG->wwwroot . '/mod/quiz/attempt.php?attempt=' . $attemptid;
316         if ($page) {
317             $url .= '&page=' . $page;
318         }
319         return $url;
320     }
322     /**
323      * @return string the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter.
324      */
325     public function start_attempt_url($page = 0) {
326         $params = array('cmid' => $this->cm->id, 'sesskey' => sesskey());
327         if ($page) {
328             $params['page'] = $page;
329         }
330         return new moodle_url('/mod/quiz/startattempt.php', $params);
331     }
333     /**
334      * @param int $attemptid the id of an attempt.
335      * @return string the URL of the review of that attempt.
336      */
337     public function review_url($attemptid) {
338         return new moodle_url('/mod/quiz/review.php', array('attempt' => $attemptid));
339     }
341     /**
342      * @param int $attemptid the id of an attempt.
343      * @return string the URL of the review of that attempt.
344      */
345     public function summary_url($attemptid) {
346         return new moodle_url('/mod/quiz/summary.php', array('attempt' => $attemptid));
347     }
349     // Bits of content =========================================================
351     /**
352      * @param bool $unfinished whether there is currently an unfinished attempt active.
353      * @return string if the quiz policies merit it, return a warning string to
354      *      be displayed in a javascript alert on the start attempt button.
355      */
356     public function confirm_start_attempt_message($unfinished) {
357         if ($unfinished) {
358             return '';
359         }
361         if ($this->quiz->timelimit && $this->quiz->attempts) {
362             return get_string('confirmstartattempttimelimit', 'quiz', $this->quiz->attempts);
363         } else if ($this->quiz->timelimit) {
364             return get_string('confirmstarttimelimit', 'quiz');
365         } else if ($this->quiz->attempts) {
366             return get_string('confirmstartattemptlimit', 'quiz', $this->quiz->attempts);
367         }
369         return '';
370     }
372     /**
373      * If $reviewoptions->attempt is false, meaning that students can't review this
374      * attempt at the moment, return an appropriate string explaining why.
375      *
376      * @param int $when One of the mod_quiz_display_options::DURING,
377      *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
378      * @param bool $short if true, return a shorter string.
379      * @return string an appropraite message.
380      */
381     public function cannot_review_message($when, $short = false) {
383         if ($short) {
384             $langstrsuffix = 'short';
385             $dateformat = get_string('strftimedatetimeshort', 'langconfig');
386         } else {
387             $langstrsuffix = '';
388             $dateformat = '';
389         }
391         if ($when == mod_quiz_display_options::DURING ||
392                 $when == mod_quiz_display_options::IMMEDIATELY_AFTER) {
393             return '';
394         } else if ($when == mod_quiz_display_options::LATER_WHILE_OPEN && $this->quiz->timeclose &&
395                 $this->quiz->reviewattempt & mod_quiz_display_options::AFTER_CLOSE) {
396             return get_string('noreviewuntil' . $langstrsuffix, 'quiz',
397                     userdate($this->quiz->timeclose, $dateformat));
398         } else {
399             return get_string('noreview' . $langstrsuffix, 'quiz');
400         }
401     }
403     /**
404      * @param string $title the name of this particular quiz page.
405      * @return array the data that needs to be sent to print_header_simple as the $navigation
406      * parameter.
407      */
408     public function navigation($title) {
409         global $PAGE;
410         $PAGE->navbar->add($title);
411         return '';
412     }
414     // Private methods =========================================================
415     /**
416      * Check that the definition of a particular question is loaded, and if not throw an exception.
417      * @param $id a questionid.
418      */
419     protected function ensure_question_loaded($id) {
420         if (isset($this->questions[$id]->_partiallyloaded)) {
421             throw new moodle_quiz_exception($this, 'questionnotloaded', $id);
422         }
423     }
427 /**
428  * This class extends the quiz class to hold data about the state of a particular attempt,
429  * in addition to the data about the quiz.
430  *
431  * @copyright  2008 Tim Hunt
432  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
433  * @since      Moodle 2.0
434  */
435 class quiz_attempt {
437     /** @var string to identify the in progress state. */
438     const IN_PROGRESS = 'inprogress';
439     /** @var string to identify the overdue state. */
440     const OVERDUE     = 'overdue';
441     /** @var string to identify the finished state. */
442     const FINISHED    = 'finished';
443     /** @var string to identify the abandoned state. */
444     const ABANDONED   = 'abandoned';
446     /** @var int maximum number of slots in the quiz for the review page to default to show all. */
447     const MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL = 50;
449     // Basic data.
450     protected $quizobj;
451     protected $attempt;
453     /** @var question_usage_by_activity the question usage for this quiz attempt. */
454     protected $quba;
456     /** @var array page no => array of slot numbers on the page in order. */
457     protected $pagelayout;
459     /** @var array slot => displayed question number for this slot. (E.g. 1, 2, 3 or 'i'.) */
460     protected $questionnumbers;
462     /** @var array slot => page number for this slot. */
463     protected $questionpages;
465     /** @var mod_quiz_display_options cache for the appropriate review options. */
466     protected $reviewoptions = null;
468     // Constructor =============================================================
469     /**
470      * Constructor assuming we already have the necessary data loaded.
471      *
472      * @param object $attempt the row of the quiz_attempts table.
473      * @param object $quiz the quiz object for this attempt and user.
474      * @param object $cm the course_module object for this quiz.
475      * @param object $course the row from the course table for the course we belong to.
476      * @param bool $loadquestions (optional) if true, the default, load all the details
477      *      of the state of each question. Else just set up the basic details of the attempt.
478      */
479     public function __construct($attempt, $quiz, $cm, $course, $loadquestions = true) {
480         $this->attempt = $attempt;
481         $this->quizobj = new quiz($quiz, $cm, $course);
483         if (!$loadquestions) {
484             return;
485         }
487         $this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid);
488         $this->determine_layout();
489         $this->number_questions();
490     }
492     /**
493      * Used by {create()} and {create_from_usage_id()}.
494      * @param array $conditions passed to $DB->get_record('quiz_attempts', $conditions).
495      */
496     protected static function create_helper($conditions) {
497         global $DB;
499         $attempt = $DB->get_record('quiz_attempts', $conditions, '*', MUST_EXIST);
500         $quiz = quiz_access_manager::load_quiz_and_settings($attempt->quiz);
501         $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
502         $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST);
504         // Update quiz with override information.
505         $quiz = quiz_update_effective_access($quiz, $attempt->userid);
507         return new quiz_attempt($attempt, $quiz, $cm, $course);
508     }
510     /**
511      * Static function to create a new quiz_attempt object given an attemptid.
512      *
513      * @param int $attemptid the attempt id.
514      * @return quiz_attempt the new quiz_attempt object
515      */
516     public static function create($attemptid) {
517         return self::create_helper(array('id' => $attemptid));
518     }
520     /**
521      * Static function to create a new quiz_attempt object given a usage id.
522      *
523      * @param int $usageid the attempt usage id.
524      * @return quiz_attempt the new quiz_attempt object
525      */
526     public static function create_from_usage_id($usageid) {
527         return self::create_helper(array('uniqueid' => $usageid));
528     }
530     /**
531      * @param string $state one of the state constants like IN_PROGRESS.
532      * @return string the human-readable state name.
533      */
534     public static function state_name($state) {
535         return quiz_attempt_state_name($state);
536     }
538     /**
539      * Parse attempt->layout to populate the other arrays the represent the layout.
540      */
541     protected function determine_layout() {
542         $this->pagelayout = array();
544         // Break up the layout string into pages.
545         $pagelayouts = explode(',0', $this->attempt->layout);
547         // Strip off any empty last page (normally there is one).
548         if (end($pagelayouts) == '') {
549             array_pop($pagelayouts);
550         }
552         // File the ids into the arrays.
553         $this->pagelayout = array();
554         foreach ($pagelayouts as $page => $pagelayout) {
555             $pagelayout = trim($pagelayout, ',');
556             if ($pagelayout == '') {
557                 continue;
558             }
559             $this->pagelayout[$page] = explode(',', $pagelayout);
560         }
561     }
563     /**
564      * Work out the number to display for each question/slot.
565      */
566     protected function number_questions() {
567         $number = 1;
568         foreach ($this->pagelayout as $page => $slots) {
569             foreach ($slots as $slot) {
570                 if ($length = $this->is_real_question($slot)) {
571                     $this->questionnumbers[$slot] = $number;
572                     $number += $length;
573                 } else {
574                     $this->questionnumbers[$slot] = get_string('infoshort', 'quiz');
575                 }
576                 $this->questionpages[$slot] = $page;
577             }
578         }
579     }
581     /**
582      * If the given page number is out of range (before the first page, or after
583      * the last page, chnage it to be within range).
584      * @param int $page the requested page number.
585      * @return int a safe page number to use.
586      */
587     public function force_page_number_into_range($page) {
588         return min(max($page, 0), count($this->pagelayout) - 1);
589     }
591     // Simple getters ==========================================================
592     public function get_quiz() {
593         return $this->quizobj->get_quiz();
594     }
596     public function get_quizobj() {
597         return $this->quizobj;
598     }
600     /** @return int the course id. */
601     public function get_courseid() {
602         return $this->quizobj->get_courseid();
603     }
605     /** @return int the course id. */
606     public function get_course() {
607         return $this->quizobj->get_course();
608     }
610     /** @return int the quiz id. */
611     public function get_quizid() {
612         return $this->quizobj->get_quizid();
613     }
615     /** @return string the name of this quiz. */
616     public function get_quiz_name() {
617         return $this->quizobj->get_quiz_name();
618     }
620     /** @return int the quiz navigation method. */
621     public function get_navigation_method() {
622         return $this->quizobj->get_navigation_method();
623     }
625     /** @return object the course_module object. */
626     public function get_cm() {
627         return $this->quizobj->get_cm();
628     }
630     /** @return object the course_module object. */
631     public function get_cmid() {
632         return $this->quizobj->get_cmid();
633     }
635     /**
636      * @return bool wether the current user is someone who previews the quiz,
637      * rather than attempting it.
638      */
639     public function is_preview_user() {
640         return $this->quizobj->is_preview_user();
641     }
643     /** @return int the number of attempts allowed at this quiz (0 = infinite). */
644     public function get_num_attempts_allowed() {
645         return $this->quizobj->get_num_attempts_allowed();
646     }
648     /** @return int number fo pages in this quiz. */
649     public function get_num_pages() {
650         return count($this->pagelayout);
651     }
653     /**
654      * @param int $timenow the current time as a unix timestamp.
655      * @return quiz_access_manager and instance of the quiz_access_manager class
656      *      for this quiz at this time.
657      */
658     public function get_access_manager($timenow) {
659         return $this->quizobj->get_access_manager($timenow);
660     }
662     /** @return int the attempt id. */
663     public function get_attemptid() {
664         return $this->attempt->id;
665     }
667     /** @return int the attempt unique id. */
668     public function get_uniqueid() {
669         return $this->attempt->uniqueid;
670     }
672     /** @return object the row from the quiz_attempts table. */
673     public function get_attempt() {
674         return $this->attempt;
675     }
677     /** @return int the number of this attemp (is it this user's first, second, ... attempt). */
678     public function get_attempt_number() {
679         return $this->attempt->attempt;
680     }
682     /** @return string one of the quiz_attempt::IN_PROGRESS, FINISHED, OVERDUE or ABANDONED constants. */
683     public function get_state() {
684         return $this->attempt->state;
685     }
687     /** @return int the id of the user this attempt belongs to. */
688     public function get_userid() {
689         return $this->attempt->userid;
690     }
692     /** @return int the current page of the attempt. */
693     public function get_currentpage() {
694         return $this->attempt->currentpage;
695     }
697     public function get_sum_marks() {
698         return $this->attempt->sumgrades;
699     }
701     /**
702      * @return bool whether this attempt has been finished (true) or is still
703      *     in progress (false). Be warned that this is not just state == self::FINISHED,
704      *     it also includes self::ABANDONED.
705      */
706     public function is_finished() {
707         return $this->attempt->state == self::FINISHED || $this->attempt->state == self::ABANDONED;
708     }
710     /** @return bool whether this attempt is a preview attempt. */
711     public function is_preview() {
712         return $this->attempt->preview;
713     }
715     /**
716      * Is this a student dealing with their own attempt/teacher previewing,
717      * or someone with 'mod/quiz:viewreports' reviewing someone elses attempt.
718      *
719      * @return bool whether this situation should be treated as someone looking at their own
720      * attempt. The distinction normally only matters when an attempt is being reviewed.
721      */
722     public function is_own_attempt() {
723         global $USER;
724         return $this->attempt->userid == $USER->id &&
725                 (!$this->is_preview_user() || $this->attempt->preview);
726     }
728     /**
729      * @return bool whether this attempt is a preview belonging to the current user.
730      */
731     public function is_own_preview() {
732         global $USER;
733         return $this->attempt->userid == $USER->id &&
734                 $this->is_preview_user() && $this->attempt->preview;
735     }
737     /**
738      * Is the current user allowed to review this attempt. This applies when
739      * {@link is_own_attempt()} returns false.
740      * @return bool whether the review should be allowed.
741      */
742     public function is_review_allowed() {
743         if (!$this->has_capability('mod/quiz:viewreports')) {
744             return false;
745         }
747         $cm = $this->get_cm();
748         if ($this->has_capability('moodle/site:accessallgroups') ||
749                 groups_get_activity_groupmode($cm) != SEPARATEGROUPS) {
750             return true;
751         }
753         // Check the users have at least one group in common.
754         $teachersgroups = groups_get_activity_allowed_groups($cm);
755         $studentsgroups = groups_get_all_groups(
756                 $cm->course, $this->attempt->userid, $cm->groupingid);
757         return $teachersgroups && $studentsgroups &&
758                 array_intersect(array_keys($teachersgroups), array_keys($studentsgroups));
759     }
761     /**
762      * Get extra summary information about this attempt.
763      *
764      * Some behaviours may be able to provide interesting summary information
765      * about the attempt as a whole, and this method provides access to that data.
766      * To see how this works, try setting a quiz to one of the CBM behaviours,
767      * and then look at the extra information displayed at the top of the quiz
768      * review page once you have sumitted an attempt.
769      *
770      * In the return value, the array keys are identifiers of the form
771      * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary.
772      * The values are arrays with two items, title and content. Each of these
773      * will be either a string, or a renderable.
774      *
775      * @return array as described above.
776      */
777     public function get_additional_summary_data(question_display_options $options) {
778         return $this->quba->get_summary_information($options);
779     }
781     /**
782      * Get the overall feedback corresponding to a particular mark.
783      * @param $grade a particular grade.
784      */
785     public function get_overall_feedback($grade) {
786         return quiz_feedback_for_grade($grade, $this->get_quiz(),
787                 $this->quizobj->get_context());
788     }
790     /**
791      * Wrapper round the has_capability funciton that automatically passes in the quiz context.
792      */
793     public function has_capability($capability, $userid = null, $doanything = true) {
794         return $this->quizobj->has_capability($capability, $userid, $doanything);
795     }
797     /**
798      * Wrapper round the require_capability funciton that automatically passes in the quiz context.
799      */
800     public function require_capability($capability, $userid = null, $doanything = true) {
801         return $this->quizobj->require_capability($capability, $userid, $doanything);
802     }
804     /**
805      * Check the appropriate capability to see whether this user may review their own attempt.
806      * If not, prints an error.
807      */
808     public function check_review_capability() {
809         if ($this->get_attempt_state() == mod_quiz_display_options::IMMEDIATELY_AFTER) {
810             $capability = 'mod/quiz:attempt';
811         } else {
812             $capability = 'mod/quiz:reviewmyattempts';
813         }
815         // These next tests are in a slighly funny order. The point is that the
816         // common and most performance-critical case is students attempting a quiz
817         // so we want to check that permisison first.
819         if ($this->has_capability($capability)) {
820             // User has the permission that lets you do the quiz as a student. Fine.
821             return;
822         }
824         if ($this->has_capability('mod/quiz:viewreports') ||
825                 $this->has_capability('mod/quiz:preview')) {
826             // User has the permission that lets teachers review. Fine.
827             return;
828         }
830         // They should not be here. Trigger the standard no-permission error
831         // but using the name of the student capability.
832         // We know this will fail. We just want the stadard exception thown.
833         $this->require_capability($capability);
834     }
836     /**
837      * Checks whether a user may navigate to a particular slot
838      */
839     public function can_navigate_to($slot) {
840         switch ($this->get_navigation_method()) {
841             case QUIZ_NAVMETHOD_FREE:
842                 return true;
843                 break;
844             case QUIZ_NAVMETHOD_SEQ:
845                 return false;
846                 break;
847         }
848         return true;
849     }
851     /**
852      * @return int one of the mod_quiz_display_options::DURING,
853      *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
854      */
855     public function get_attempt_state() {
856         return quiz_attempt_state($this->get_quiz(), $this->attempt);
857     }
859     /**
860      * Wrapper that the correct mod_quiz_display_options for this quiz at the
861      * moment.
862      *
863      * @return question_display_options the render options for this user on this attempt.
864      */
865     public function get_display_options($reviewing) {
866         if ($reviewing) {
867             if (is_null($this->reviewoptions)) {
868                 $this->reviewoptions = quiz_get_review_options($this->get_quiz(),
869                         $this->attempt, $this->quizobj->get_context());
870             }
871             return $this->reviewoptions;
873         } else {
874             $options = mod_quiz_display_options::make_from_quiz($this->get_quiz(),
875                     mod_quiz_display_options::DURING);
876             $options->flags = quiz_get_flag_option($this->attempt, $this->quizobj->get_context());
877             return $options;
878         }
879     }
881     /**
882      * Wrapper that the correct mod_quiz_display_options for this quiz at the
883      * moment.
884      *
885      * @param bool $reviewing true for review page, else attempt page.
886      * @param int $slot which question is being displayed.
887      * @param moodle_url $thispageurl to return to after the editing form is
888      *      submitted or cancelled. If null, no edit link will be generated.
889      *
890      * @return question_display_options the render options for this user on this
891      *      attempt, with extra info to generate an edit link, if applicable.
892      */
893     public function get_display_options_with_edit_link($reviewing, $slot, $thispageurl) {
894         $options = clone($this->get_display_options($reviewing));
896         if (!$thispageurl) {
897             return $options;
898         }
900         if (!($reviewing || $this->is_preview())) {
901             return $options;
902         }
904         $question = $this->quba->get_question($slot);
905         if (!question_has_capability_on($question, 'edit', $question->category)) {
906             return $options;
907         }
909         $options->editquestionparams['cmid'] = $this->get_cmid();
910         $options->editquestionparams['returnurl'] = $thispageurl;
912         return $options;
913     }
915     /**
916      * @param int $page page number
917      * @return bool true if this is the last page of the quiz.
918      */
919     public function is_last_page($page) {
920         return $page == count($this->pagelayout) - 1;
921     }
923     /**
924      * Return the list of question ids for either a given page of the quiz, or for the
925      * whole quiz.
926      *
927      * @param mixed $page string 'all' or integer page number.
928      * @return array the reqested list of question ids.
929      */
930     public function get_slots($page = 'all') {
931         if ($page === 'all') {
932             $numbers = array();
933             foreach ($this->pagelayout as $numbersonpage) {
934                 $numbers = array_merge($numbers, $numbersonpage);
935             }
936             return $numbers;
937         } else {
938             return $this->pagelayout[$page];
939         }
940     }
942     /**
943      * Get the question_attempt object for a particular question in this attempt.
944      * @param int $slot the number used to identify this question within this attempt.
945      * @return question_attempt
946      */
947     public function get_question_attempt($slot) {
948         return $this->quba->get_question_attempt($slot);
949     }
951     /**
952      * Is a particular question in this attempt a real question, or something like a description.
953      * @param int $slot the number used to identify this question within this attempt.
954      * @return int whether that question is a real question. Actually returns the
955      *     question length, which could theoretically be greater than one.
956      */
957     public function is_real_question($slot) {
958         return $this->quba->get_question($slot)->length;
959     }
961     /**
962      * Is a particular question in this attempt a real question, or something like a description.
963      * @param int $slot the number used to identify this question within this attempt.
964      * @return bool whether that question is a real question.
965      */
966     public function is_question_flagged($slot) {
967         return $this->quba->get_question_attempt($slot)->is_flagged();
968     }
970     /**
971      * @param int $slot the number used to identify this question within this attempt.
972      * @return string the displayed question number for the question in this slot.
973      *      For example '1', '2', '3' or 'i'.
974      */
975     public function get_question_number($slot) {
976         return $this->questionnumbers[$slot];
977     }
979     /**
980      * @param int $slot the number used to identify this question within this attempt.
981      * @return int the page of the quiz this question appears on.
982      */
983     public function get_question_page($slot) {
984         return $this->questionpages[$slot];
985     }
987     /**
988      * Return the grade obtained on a particular question, if the user is permitted
989      * to see it. You must previously have called load_question_states to load the
990      * state data about this question.
991      *
992      * @param int $slot the number used to identify this question within this attempt.
993      * @return string the formatted grade, to the number of decimal places specified
994      *      by the quiz.
995      */
996     public function get_question_name($slot) {
997         return $this->quba->get_question($slot)->name;
998     }
1000     /**
1001      * Return the grade obtained on a particular question, if the user is permitted
1002      * to see it. You must previously have called load_question_states to load the
1003      * state data about this question.
1004      *
1005      * @param int $slot the number used to identify this question within this attempt.
1006      * @param bool $showcorrectness Whether right/partial/wrong states should
1007      * be distinguised.
1008      * @return string the formatted grade, to the number of decimal places specified
1009      *      by the quiz.
1010      */
1011     public function get_question_status($slot, $showcorrectness) {
1012         return $this->quba->get_question_state_string($slot, $showcorrectness);
1013     }
1015     /**
1016      * Return the grade obtained on a particular question, if the user is permitted
1017      * to see it. You must previously have called load_question_states to load the
1018      * state data about this question.
1019      *
1020      * @param int $slot the number used to identify this question within this attempt.
1021      * @param bool $showcorrectness Whether right/partial/wrong states should
1022      * be distinguised.
1023      * @return string class name for this state.
1024      */
1025     public function get_question_state_class($slot, $showcorrectness) {
1026         return $this->quba->get_question_state_class($slot, $showcorrectness);
1027     }
1029     /**
1030      * Return the grade obtained on a particular question.
1031      * You must previously have called load_question_states to load the state
1032      * data about this question.
1033      *
1034      * @param int $slot the number used to identify this question within this attempt.
1035      * @return string the formatted grade, to the number of decimal places specified by the quiz.
1036      */
1037     public function get_question_mark($slot) {
1038         return quiz_format_question_grade($this->get_quiz(), $this->quba->get_question_mark($slot));
1039     }
1041     /**
1042      * Get the time of the most recent action performed on a question.
1043      * @param int $slot the number used to identify this question within this usage.
1044      * @return int timestamp.
1045      */
1046     public function get_question_action_time($slot) {
1047         return $this->quba->get_question_action_time($slot);
1048     }
1050     /**
1051      * Get the time remaining for an in-progress attempt, if the time is short
1052      * enought that it would be worth showing a timer.
1053      * @param int $timenow the time to consider as 'now'.
1054      * @return int|false the number of seconds remaining for this attempt.
1055      *      False if there is no limit.
1056      */
1057     public function get_time_left_display($timenow) {
1058         if ($this->attempt->state != self::IN_PROGRESS) {
1059             return false;
1060         }
1061         return $this->get_access_manager($timenow)->get_time_left_display($this->attempt, $timenow);
1062     }
1065     /**
1066      * @return int the time when this attempt was submitted. 0 if it has not been
1067      * submitted yet.
1068      */
1069     public function get_submitted_date() {
1070         return $this->attempt->timefinish;
1071     }
1073     /**
1074      * If the attempt is in an applicable state, work out the time by which the
1075      * student should next do something.
1076      * @return int timestamp by which the student needs to do something.
1077      */
1078     public function get_due_date() {
1079         $deadlines = array();
1080         if ($this->quizobj->get_quiz()->timelimit) {
1081             $deadlines[] = $this->attempt->timestart + $this->quizobj->get_quiz()->timelimit;
1082         }
1083         if ($this->quizobj->get_quiz()->timeclose) {
1084             $deadlines[] = $this->quizobj->get_quiz()->timeclose;
1085         }
1086         if ($deadlines) {
1087             $duedate = min($deadlines);
1088         } else {
1089             return false;
1090         }
1092         switch ($this->attempt->state) {
1093             case self::IN_PROGRESS:
1094                 return $duedate;
1096             case self::OVERDUE:
1097                 return $duedate + $this->quizobj->get_quiz()->graceperiod;
1099             default:
1100                 throw new coding_exception('Unexpected state: ' . $this->attempt->state);
1101         }
1102     }
1104     // URLs related to this attempt ============================================
1105     /**
1106      * @return string quiz view url.
1107      */
1108     public function view_url() {
1109         return $this->quizobj->view_url();
1110     }
1112     /**
1113      * @return string the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter.
1114      */
1115     public function start_attempt_url($slot = null, $page = -1) {
1116         if ($page == -1 && !is_null($slot)) {
1117             $page = $this->get_question_page($slot);
1118         } else {
1119             $page = 0;
1120         }
1121         return $this->quizobj->start_attempt_url($page);
1122     }
1124     /**
1125      * @param int $slot if speified, the slot number of a specific question to link to.
1126      * @param int $page if specified, a particular page to link to. If not givem deduced
1127      *      from $slot, or goes to the first page.
1128      * @param int $questionid a question id. If set, will add a fragment to the URL
1129      * to jump to a particuar question on the page.
1130      * @param int $thispage if not -1, the current page. Will cause links to other things on
1131      * this page to be output as only a fragment.
1132      * @return string the URL to continue this attempt.
1133      */
1134     public function attempt_url($slot = null, $page = -1, $thispage = -1) {
1135         return $this->page_and_question_url('attempt', $slot, $page, false, $thispage);
1136     }
1138     /**
1139      * @return string the URL of this quiz's summary page.
1140      */
1141     public function summary_url() {
1142         return new moodle_url('/mod/quiz/summary.php', array('attempt' => $this->attempt->id));
1143     }
1145     /**
1146      * @return string the URL of this quiz's summary page.
1147      */
1148     public function processattempt_url() {
1149         return new moodle_url('/mod/quiz/processattempt.php');
1150     }
1152     /**
1153      * @param int $slot indicates which question to link to.
1154      * @param int $page if specified, the URL of this particular page of the attempt, otherwise
1155      * the URL will go to the first page.  If -1, deduce $page from $slot.
1156      * @param bool|null $showall if true, the URL will be to review the entire attempt on one page,
1157      * and $page will be ignored. If null, a sensible default will be chosen.
1158      * @param int $thispage if not -1, the current page. Will cause links to other things on
1159      * this page to be output as only a fragment.
1160      * @return string the URL to review this attempt.
1161      */
1162     public function review_url($slot = null, $page = -1, $showall = null, $thispage = -1) {
1163         return $this->page_and_question_url('review', $slot, $page, $showall, $thispage);
1164     }
1166     /**
1167      * By default, should this script show all questions on one page for this attempt?
1168      * @param string $script the script name, e.g. 'attempt', 'summary', 'review'.
1169      * @return whether show all on one page should be on by default.
1170      */
1171     public function get_default_show_all($script) {
1172         return $script == 'review' && count($this->questionpages) < self::MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL;
1173     }
1175     // Bits of content =========================================================
1177     /**
1178      * If $reviewoptions->attempt is false, meaning that students can't review this
1179      * attempt at the moment, return an appropriate string explaining why.
1180      *
1181      * @param bool $short if true, return a shorter string.
1182      * @return string an appropraite message.
1183      */
1184     public function cannot_review_message($short = false) {
1185         return $this->quizobj->cannot_review_message(
1186                 $this->get_attempt_state(), $short);
1187     }
1189     /**
1190      * Initialise the JS etc. required all the questions on a page.
1191      * @param mixed $page a page number, or 'all'.
1192      */
1193     public function get_html_head_contributions($page = 'all', $showall = false) {
1194         if ($showall) {
1195             $page = 'all';
1196         }
1197         $result = '';
1198         foreach ($this->get_slots($page) as $slot) {
1199             $result .= $this->quba->render_question_head_html($slot);
1200         }
1201         $result .= question_engine::initialise_js();
1202         return $result;
1203     }
1205     /**
1206      * Initialise the JS etc. required by one question.
1207      * @param int $questionid the question id.
1208      */
1209     public function get_question_html_head_contributions($slot) {
1210         return $this->quba->render_question_head_html($slot) .
1211                 question_engine::initialise_js();
1212     }
1214     /**
1215      * Print the HTML for the start new preview button, if the current user
1216      * is allowed to see one.
1217      */
1218     public function restart_preview_button() {
1219         global $OUTPUT;
1220         if ($this->is_preview() && $this->is_preview_user()) {
1221             return $OUTPUT->single_button(new moodle_url(
1222                     $this->start_attempt_url(), array('forcenew' => true)),
1223                     get_string('startnewpreview', 'quiz'));
1224         } else {
1225             return '';
1226         }
1227     }
1229     /**
1230      * Generate the HTML that displayes the question in its current state, with
1231      * the appropriate display options.
1232      *
1233      * @param int $id the id of a question in this quiz attempt.
1234      * @param bool $reviewing is the being printed on an attempt or a review page.
1235      * @param moodle_url $thispageurl the URL of the page this question is being printed on.
1236      * @return string HTML for the question in its current state.
1237      */
1238     public function render_question($slot, $reviewing, $thispageurl = null) {
1239         return $this->quba->render_question($slot,
1240                 $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl),
1241                 $this->get_question_number($slot));
1242     }
1244     /**
1245      * Like {@link render_question()} but displays the question at the past step
1246      * indicated by $seq, rather than showing the latest step.
1247      *
1248      * @param int $id the id of a question in this quiz attempt.
1249      * @param int $seq the seq number of the past state to display.
1250      * @param bool $reviewing is the being printed on an attempt or a review page.
1251      * @param string $thispageurl the URL of the page this question is being printed on.
1252      * @return string HTML for the question in its current state.
1253      */
1254     public function render_question_at_step($slot, $seq, $reviewing, $thispageurl = '') {
1255         return $this->quba->render_question_at_step($slot, $seq,
1256                 $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl),
1257                 $this->get_question_number($slot));
1258     }
1260     /**
1261      * Wrapper round print_question from lib/questionlib.php.
1262      *
1263      * @param int $id the id of a question in this quiz attempt.
1264      */
1265     public function render_question_for_commenting($slot) {
1266         $options = $this->get_display_options(true);
1267         $options->hide_all_feedback();
1268         $options->manualcomment = question_display_options::EDITABLE;
1269         return $this->quba->render_question($slot, $options,
1270                 $this->get_question_number($slot));
1271     }
1273     /**
1274      * Check wheter access should be allowed to a particular file.
1275      *
1276      * @param int $id the id of a question in this quiz attempt.
1277      * @param bool $reviewing is the being printed on an attempt or a review page.
1278      * @param string $thispageurl the URL of the page this question is being printed on.
1279      * @return string HTML for the question in its current state.
1280      */
1281     public function check_file_access($slot, $reviewing, $contextid, $component,
1282             $filearea, $args, $forcedownload) {
1283         return $this->quba->check_file_access($slot, $this->get_display_options($reviewing),
1284                 $component, $filearea, $args, $forcedownload);
1285     }
1287     /**
1288      * Get the navigation panel object for this attempt.
1289      *
1290      * @param $panelclass The type of panel, quiz_attempt_nav_panel or quiz_review_nav_panel
1291      * @param $page the current page number.
1292      * @param $showall whether we are showing the whole quiz on one page. (Used by review.php)
1293      * @return quiz_nav_panel_base the requested object.
1294      */
1295     public function get_navigation_panel(mod_quiz_renderer $output,
1296              $panelclass, $page, $showall = false) {
1297         $panel = new $panelclass($this, $this->get_display_options(true), $page, $showall);
1299         $bc = new block_contents();
1300         $bc->attributes['id'] = 'mod_quiz_navblock';
1301         $bc->title = get_string('quiznavigation', 'quiz');
1302         $bc->content = $output->navigation_panel($panel);
1303         return $bc;
1304     }
1306     /**
1307      * Given a URL containing attempt={this attempt id}, return an array of variant URLs
1308      * @param moodle_url $url a URL.
1309      * @return string HTML fragment. Comma-separated list of links to the other
1310      * attempts with the attempt number as the link text. The curent attempt is
1311      * included but is not a link.
1312      */
1313     public function links_to_other_attempts(moodle_url $url) {
1314         $attempts = quiz_get_user_attempts($this->get_quiz()->id, $this->attempt->userid, 'all');
1315         if (count($attempts) <= 1) {
1316             return false;
1317         }
1319         $links = new mod_quiz_links_to_other_attempts();
1320         foreach ($attempts as $at) {
1321             if ($at->id == $this->attempt->id) {
1322                 $links->links[$at->attempt] = null;
1323             } else {
1324                 $links->links[$at->attempt] = new moodle_url($url, array('attempt' => $at->id));
1325             }
1326         }
1327         return $links;
1328     }
1330     // Methods for processing ==================================================
1332     /**
1333      * Check this attempt, to see if there are any state transitions that should
1334      * happen automatically.  This function will update the attempt checkstatetime.
1335      * @param int $timestamp the timestamp that should be stored as the modifed
1336      * @param bool $studentisonline is the student currently interacting with Moodle?
1337      */
1338     public function handle_if_time_expired($timestamp, $studentisonline) {
1339         global $DB;
1341         $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt);
1343         if ($timeclose === false || $this->is_preview()) {
1344             $this->update_timecheckstate(null);
1345             return; // No time limit
1346         }
1347         if ($timestamp < $timeclose) {
1348             $this->update_timecheckstate($timeclose);
1349             return; // Time has not yet expired.
1350         }
1352         // If the attempt is already overdue, look to see if it should be abandoned ...
1353         if ($this->attempt->state == self::OVERDUE) {
1354             $timeoverdue = $timestamp - $timeclose;
1355             $graceperiod = $this->quizobj->get_quiz()->graceperiod;
1356             if ($timeoverdue >= $graceperiod) {
1357                 $this->process_abandon($timestamp, $studentisonline);
1358             } else {
1359                 // Overdue time has not yet expired
1360                 $this->update_timecheckstate($timeclose + $graceperiod);
1361             }
1362             return; // ... and we are done.
1363         }
1365         if ($this->attempt->state != self::IN_PROGRESS) {
1366             $this->update_timecheckstate(null);
1367             return; // Attempt is already in a final state.
1368         }
1370         // Otherwise, we were in quiz_attempt::IN_PROGRESS, and time has now expired.
1371         // Transition to the appropriate state.
1372         switch ($this->quizobj->get_quiz()->overduehandling) {
1373             case 'autosubmit':
1374                 $this->process_finish($timestamp, false);
1375                 return;
1377             case 'graceperiod':
1378                 $this->process_going_overdue($timestamp, $studentisonline);
1379                 return;
1381             case 'autoabandon':
1382                 $this->process_abandon($timestamp, $studentisonline);
1383                 return;
1384         }
1386         // This is an overdue attempt with no overdue handling defined, so just abandon.
1387         $this->process_abandon($timestamp, $studentisonline);
1388         return;
1389     }
1391     /**
1392      * Process all the actions that were submitted as part of the current request.
1393      *
1394      * @param int  $timestamp  the timestamp that should be stored as the modifed
1395      *                         time in the database for these actions. If null, will use the current time.
1396      * @param bool $becomingoverdue
1397      * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data, keys are slot
1398      *                                          nos and values are arrays representing student responses which will be passed to
1399      *                                          question_definition::prepare_simulated_post_data method and then have the
1400      *                                          appropriate prefix added.
1401      */
1402     public function process_submitted_actions($timestamp, $becomingoverdue = false, $simulatedresponses = null) {
1403         global $DB;
1405         $transaction = $DB->start_delegated_transaction();
1407         if ($simulatedresponses !== null) {
1408             $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses);
1409         } else {
1410             $simulatedpostdata = null;
1411         }
1413         $this->quba->process_all_actions($timestamp, $simulatedpostdata);
1414         question_engine::save_questions_usage_by_activity($this->quba);
1416         $this->attempt->timemodified = $timestamp;
1417         if ($this->attempt->state == self::FINISHED) {
1418             $this->attempt->sumgrades = $this->quba->get_total_mark();
1419         }
1420         if ($becomingoverdue) {
1421             $this->process_going_overdue($timestamp, true);
1422         } else {
1423             $DB->update_record('quiz_attempts', $this->attempt);
1424         }
1426         if (!$this->is_preview() && $this->attempt->state == self::FINISHED) {
1427             quiz_save_best_grade($this->get_quiz(), $this->get_userid());
1428         }
1430         $transaction->allow_commit();
1431     }
1433     /**
1434      * Process all the autosaved data that was part of the current request.
1435      *
1436      * @param int $timestamp the timestamp that should be stored as the modifed
1437      * time in the database for these actions. If null, will use the current time.
1438      */
1439     public function process_auto_save($timestamp) {
1440         global $DB;
1442         $transaction = $DB->start_delegated_transaction();
1444         $this->quba->process_all_autosaves($timestamp);
1445         question_engine::save_questions_usage_by_activity($this->quba);
1447         $transaction->allow_commit();
1448     }
1450     /**
1451      * Update the flagged state for all question_attempts in this usage, if their
1452      * flagged state was changed in the request.
1453      */
1454     public function save_question_flags() {
1455         global $DB;
1457         $transaction = $DB->start_delegated_transaction();
1458         $this->quba->update_question_flags();
1459         question_engine::save_questions_usage_by_activity($this->quba);
1460         $transaction->allow_commit();
1461     }
1463     public function process_finish($timestamp, $processsubmitted) {
1464         global $DB;
1466         $transaction = $DB->start_delegated_transaction();
1468         if ($processsubmitted) {
1469             $this->quba->process_all_actions($timestamp);
1470         }
1471         $this->quba->finish_all_questions($timestamp);
1473         question_engine::save_questions_usage_by_activity($this->quba);
1475         $this->attempt->timemodified = $timestamp;
1476         $this->attempt->timefinish = $timestamp;
1477         $this->attempt->sumgrades = $this->quba->get_total_mark();
1478         $this->attempt->state = self::FINISHED;
1479         $this->attempt->timecheckstate = null;
1480         $DB->update_record('quiz_attempts', $this->attempt);
1482         if (!$this->is_preview()) {
1483             quiz_save_best_grade($this->get_quiz(), $this->attempt->userid);
1485             // Trigger event.
1486             $this->fire_state_transition_event('\mod_quiz\event\attempt_submitted', $timestamp);
1488             // Tell any access rules that care that the attempt is over.
1489             $this->get_access_manager($timestamp)->current_attempt_finished();
1490         }
1492         $transaction->allow_commit();
1493     }
1495     /**
1496      * Update this attempt timecheckstate if necessary.
1497      * @param int|null the timecheckstate
1498      */
1499     public function update_timecheckstate($time) {
1500         global $DB;
1501         if ($this->attempt->timecheckstate !== $time) {
1502             $this->attempt->timecheckstate = $time;
1503             $DB->set_field('quiz_attempts', 'timecheckstate', $time, array('id'=>$this->attempt->id));
1504         }
1505     }
1507     /**
1508      * Mark this attempt as now overdue.
1509      * @param int $timestamp the time to deem as now.
1510      * @param bool $studentisonline is the student currently interacting with Moodle?
1511      */
1512     public function process_going_overdue($timestamp, $studentisonline) {
1513         global $DB;
1515         $transaction = $DB->start_delegated_transaction();
1516         $this->attempt->timemodified = $timestamp;
1517         $this->attempt->state = self::OVERDUE;
1518         // If we knew the attempt close time, we could compute when the graceperiod ends.
1519         // Instead we'll just fix it up through cron.
1520         $this->attempt->timecheckstate = $timestamp;
1521         $DB->update_record('quiz_attempts', $this->attempt);
1523         $this->fire_state_transition_event('\mod_quiz\event\attempt_becameoverdue', $timestamp);
1525         $transaction->allow_commit();
1526     }
1528     /**
1529      * Mark this attempt as abandoned.
1530      * @param int $timestamp the time to deem as now.
1531      * @param bool $studentisonline is the student currently interacting with Moodle?
1532      */
1533     public function process_abandon($timestamp, $studentisonline) {
1534         global $DB;
1536         $transaction = $DB->start_delegated_transaction();
1537         $this->attempt->timemodified = $timestamp;
1538         $this->attempt->state = self::ABANDONED;
1539         $this->attempt->timecheckstate = null;
1540         $DB->update_record('quiz_attempts', $this->attempt);
1542         $this->fire_state_transition_event('\mod_quiz\event\attempt_abandoned', $timestamp);
1544         $transaction->allow_commit();
1545     }
1547     /**
1548      * Fire a state transition event.
1549      * the same event information.
1550      * @param string $eventclass the event class name.
1551      * @param int $timestamp the timestamp to include in the event.
1552      * @return void
1553      */
1554     protected function fire_state_transition_event($eventclass, $timestamp) {
1555         global $USER;
1556         $quizrecord = $this->get_quiz();
1557         $params = array(
1558             'context' => $this->get_quizobj()->get_context(),
1559             'courseid' => $this->get_courseid(),
1560             'objectid' => $this->attempt->id,
1561             'relateduserid' => $this->attempt->userid,
1562             'other' => array(
1563                 'submitterid' => CLI_SCRIPT ? null : $USER->id,
1564                 'quizid' => $quizrecord->id
1565             )
1566         );
1568         $event = $eventclass::create($params);
1569         $event->add_record_snapshot('quiz', $this->get_quiz());
1570         $event->add_record_snapshot('quiz_attempts', $this->get_attempt());
1571         $event->trigger();
1572     }
1574     /**
1575      * Print the fields of the comment form for questions in this attempt.
1576      * @param $slot which question to output the fields for.
1577      * @param $prefix Prefix to add to all field names.
1578      */
1579     public function question_print_comment_fields($slot, $prefix) {
1580         // Work out a nice title.
1581         $student = get_record('user', 'id', $this->get_userid());
1582         $a = new object();
1583         $a->fullname = fullname($student, true);
1584         $a->attempt = $this->get_attempt_number();
1586         question_print_comment_fields($this->quba->get_question_attempt($slot),
1587                 $prefix, $this->get_display_options(true)->markdp,
1588                 get_string('gradingattempt', 'quiz_grading', $a));
1589     }
1591     // Private methods =========================================================
1593     /**
1594      * Get a URL for a particular question on a particular page of the quiz.
1595      * Used by {@link attempt_url()} and {@link review_url()}.
1596      *
1597      * @param string $script. Used in the URL like /mod/quiz/$script.php
1598      * @param int $slot identifies the specific question on the page to jump to.
1599      *      0 to just use the $page parameter.
1600      * @param int $page -1 to look up the page number from the slot, otherwise
1601      *      the page number to go to.
1602      * @param bool|null $showall if true, return a URL with showall=1, and not page number.
1603      *      if null, then an intelligent default will be chosen.
1604      * @param int $thispage the page we are currently on. Links to questions on this
1605      *      page will just be a fragment #q123. -1 to disable this.
1606      * @return The requested URL.
1607      */
1608     protected function page_and_question_url($script, $slot, $page, $showall, $thispage) {
1610         $defaultshowall = $this->get_default_show_all($script);
1611         if ($showall === null && ($page == 0 || $page == -1)) {
1612             $showall = $defaultshowall;
1613         }
1615         // Fix up $page.
1616         if ($page == -1) {
1617             if ($slot !== null && !$showall) {
1618                 $page = $this->get_question_page($slot);
1619             } else {
1620                 $page = 0;
1621             }
1622         }
1624         if ($showall) {
1625             $page = 0;
1626         }
1628         // Add a fragment to scroll down to the question.
1629         $fragment = '';
1630         if ($slot !== null) {
1631             if ($slot == reset($this->pagelayout[$page])) {
1632                 // First question on page, go to top.
1633                 $fragment = '#';
1634             } else {
1635                 $fragment = '#q' . $slot;
1636             }
1637         }
1639         // Work out the correct start to the URL.
1640         if ($thispage == $page) {
1641             return new moodle_url($fragment);
1643         } else {
1644             $url = new moodle_url('/mod/quiz/' . $script . '.php' . $fragment,
1645                     array('attempt' => $this->attempt->id));
1646             if ($page == 0 && $showall != $defaultshowall) {
1647                 $url->param('showall', (int) $showall);
1648             } else if ($page > 0) {
1649                 $url->param('page', $page);
1650             }
1651             return $url;
1652         }
1653     }
1657 /**
1658  * Represents a single link in the navigation panel.
1659  *
1660  * @copyright  2011 The Open University
1661  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1662  * @since      Moodle 2.1
1663  */
1664 class quiz_nav_question_button implements renderable {
1665     public $id;
1666     public $number;
1667     public $stateclass;
1668     public $statestring;
1669     public $currentpage;
1670     public $flagged;
1671     public $url;
1675 /**
1676  * Represents the navigation panel, and builds a {@link block_contents} to allow
1677  * it to be output.
1678  *
1679  * @copyright  2008 Tim Hunt
1680  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1681  * @since      Moodle 2.0
1682  */
1683 abstract class quiz_nav_panel_base {
1684     /** @var quiz_attempt */
1685     protected $attemptobj;
1686     /** @var question_display_options */
1687     protected $options;
1688     /** @var integer */
1689     protected $page;
1690     /** @var boolean */
1691     protected $showall;
1693     public function __construct(quiz_attempt $attemptobj,
1694             question_display_options $options, $page, $showall) {
1695         $this->attemptobj = $attemptobj;
1696         $this->options = $options;
1697         $this->page = $page;
1698         $this->showall = $showall;
1699     }
1701     public function get_question_buttons() {
1702         $buttons = array();
1703         foreach ($this->attemptobj->get_slots() as $slot) {
1704             $qa = $this->attemptobj->get_question_attempt($slot);
1705             $showcorrectness = $this->options->correctness && $qa->has_marks();
1707             $button = new quiz_nav_question_button();
1708             $button->id          = 'quiznavbutton' . $slot;
1709             $button->number      = $this->attemptobj->get_question_number($slot);
1710             $button->stateclass  = $qa->get_state_class($showcorrectness);
1711             $button->navmethod   = $this->attemptobj->get_navigation_method();
1712             if (!$showcorrectness && $button->stateclass == 'notanswered') {
1713                 $button->stateclass = 'complete';
1714             }
1715             $button->statestring = $this->get_state_string($qa, $showcorrectness);
1716             $button->currentpage = $this->showall || $this->attemptobj->get_question_page($slot) == $this->page;
1717             $button->flagged     = $qa->is_flagged();
1718             $button->url         = $this->get_question_url($slot);
1719             $buttons[] = $button;
1720         }
1722         return $buttons;
1723     }
1725     protected function get_state_string(question_attempt $qa, $showcorrectness) {
1726         if ($qa->get_question()->length > 0) {
1727             return $qa->get_state_string($showcorrectness);
1728         }
1730         // Special case handling for 'information' items.
1731         if ($qa->get_state() == question_state::$todo) {
1732             return get_string('notyetviewed', 'quiz');
1733         } else {
1734             return get_string('viewed', 'quiz');
1735         }
1736     }
1738     public function render_before_button_bits(mod_quiz_renderer $output) {
1739         return '';
1740     }
1742     abstract public function render_end_bits(mod_quiz_renderer $output);
1744     protected function render_restart_preview_link($output) {
1745         if (!$this->attemptobj->is_own_preview()) {
1746             return '';
1747         }
1748         return $output->restart_preview_button(new moodle_url(
1749                 $this->attemptobj->start_attempt_url(), array('forcenew' => true)));
1750     }
1752     protected abstract function get_question_url($slot);
1754     public function user_picture() {
1755         global $DB;
1756         if ($this->attemptobj->get_quiz()->showuserpicture == QUIZ_SHOWIMAGE_NONE) {
1757             return null;
1758         }
1759         $user = $DB->get_record('user', array('id' => $this->attemptobj->get_userid()));
1760         $userpicture = new user_picture($user);
1761         $userpicture->courseid = $this->attemptobj->get_courseid();
1762         if ($this->attemptobj->get_quiz()->showuserpicture == QUIZ_SHOWIMAGE_LARGE) {
1763             $userpicture->size = true;
1764         }
1765         return $userpicture;
1766     }
1768     /**
1769      * Return 'allquestionsononepage' as CSS class name when $showall is set,
1770      * otherwise, return 'multipages' as CSS class name.
1771      * @return string, CSS class name
1772      */
1773     public function get_button_container_class() {
1774         // Quiz navigation is set on 'Show all questions on one page'.
1775         if ($this->showall) {
1776             return 'allquestionsononepage';
1777         }
1778         // Quiz navigation is set on 'Show one page at a time'.
1779         return 'multipages';
1780     }
1784 /**
1785  * Specialisation of {@link quiz_nav_panel_base} for the attempt quiz page.
1786  *
1787  * @copyright  2008 Tim Hunt
1788  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1789  * @since      Moodle 2.0
1790  */
1791 class quiz_attempt_nav_panel extends quiz_nav_panel_base {
1792     public function get_question_url($slot) {
1793         if ($this->attemptobj->can_navigate_to($slot)) {
1794             return $this->attemptobj->attempt_url($slot, -1, $this->page);
1795         } else {
1796             return null;
1797         }
1798     }
1800     public function render_before_button_bits(mod_quiz_renderer $output) {
1801         return html_writer::tag('div', get_string('navnojswarning', 'quiz'),
1802                 array('id' => 'quiznojswarning'));
1803     }
1805     public function render_end_bits(mod_quiz_renderer $output) {
1806         return html_writer::link($this->attemptobj->summary_url(),
1807                 get_string('endtest', 'quiz'), array('class' => 'endtestlink')) .
1808                 $output->countdown_timer($this->attemptobj, time()) .
1809                 $this->render_restart_preview_link($output);
1810     }
1814 /**
1815  * Specialisation of {@link quiz_nav_panel_base} for the review quiz page.
1816  *
1817  * @copyright  2008 Tim Hunt
1818  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1819  * @since      Moodle 2.0
1820  */
1821 class quiz_review_nav_panel extends quiz_nav_panel_base {
1822     public function get_question_url($slot) {
1823         return $this->attemptobj->review_url($slot, -1, $this->showall, $this->page);
1824     }
1826     public function render_end_bits(mod_quiz_renderer $output) {
1827         $html = '';
1828         if ($this->attemptobj->get_num_pages() > 1) {
1829             if ($this->showall) {
1830                 $html .= html_writer::link($this->attemptobj->review_url(null, 0, false),
1831                         get_string('showeachpage', 'quiz'));
1832             } else {
1833                 $html .= html_writer::link($this->attemptobj->review_url(null, 0, true),
1834                         get_string('showall', 'quiz'));
1835             }
1836         }
1837         $html .= $output->finish_review_link($this->attemptobj);
1838         $html .= $this->render_restart_preview_link($output);
1839         return $html;
1840     }