MDL-38647 quiz review question issues
[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;
68     protected $questionids;
70     // Fields set later if that data is needed.
71     protected $questions = null;
72     protected $accessmanager = null;
73     protected $ispreviewuser = null;
75     // Constructor =============================================================
76     /**
77      * Constructor, assuming we already have the necessary data loaded.
78      *
79      * @param object $quiz the row from the quiz table.
80      * @param object $cm the course_module object for this quiz.
81      * @param object $course the row from the course table for the course we belong to.
82      * @param bool $getcontext intended for testing - stops the constructor getting the context.
83      */
84     public function __construct($quiz, $cm, $course, $getcontext = true) {
85         $this->quiz = $quiz;
86         $this->cm = $cm;
87         $this->quiz->cmid = $this->cm->id;
88         $this->course = $course;
89         if ($getcontext && !empty($cm->id)) {
90             $this->context = context_module::instance($cm->id);
91         }
92         $questionids = quiz_questions_in_quiz($this->quiz->questions);
93         if ($questionids) {
94             $this->questionids = explode(',', quiz_questions_in_quiz($this->quiz->questions));
95         } else {
96             $this->questionids = array(); // Which idiot made explode(',', '') = array('')?
97         }
98     }
100     /**
101      * Static function to create a new quiz object for a specific user.
102      *
103      * @param int $quizid the the quiz id.
104      * @param int $userid the the userid.
105      * @return quiz the new quiz object
106      */
107     public static function create($quizid, $userid) {
108         global $DB;
110         $quiz = quiz_access_manager::load_quiz_and_settings($quizid);
111         $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
112         $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST);
114         // Update quiz with override information.
115         $quiz = quiz_update_effective_access($quiz, $userid);
117         return new quiz($quiz, $cm, $course);
118     }
120     /**
121      * Create a {@link quiz_attempt} for an attempt at this quiz.
122      * @param object $attemptdata row from the quiz_attempts table.
123      * @return quiz_attempt the new quiz_attempt object.
124      */
125     public function create_attempt_object($attemptdata) {
126         return new quiz_attempt($attemptdata, $this->quiz, $this->cm, $this->course);
127     }
129     // Functions for loading more data =========================================
131     /**
132      * Load just basic information about all the questions in this quiz.
133      */
134     public function preload_questions() {
135         if (empty($this->questionids)) {
136             throw new moodle_quiz_exception($this, 'noquestions', $this->edit_url());
137         }
138         $this->questions = question_preload_questions($this->questionids,
139                 'qqi.grade AS maxmark, qqi.id AS instance',
140                 '{quiz_question_instances} qqi ON qqi.quiz = :quizid AND q.id = qqi.question',
141                 array('quizid' => $this->quiz->id));
142     }
144     /**
145      * Fully load some or all of the questions for this quiz. You must call
146      * {@link preload_questions()} first.
147      *
148      * @param array $questionids question ids of the questions to load. null for all.
149      */
150     public function load_questions($questionids = null) {
151         if (is_null($questionids)) {
152             $questionids = $this->questionids;
153         }
154         $questionstoprocess = array();
155         foreach ($questionids as $id) {
156             if (array_key_exists($id, $this->questions)) {
157                 $questionstoprocess[$id] = $this->questions[$id];
158             }
159         }
160         get_question_options($questionstoprocess);
161     }
163     // Simple getters ==========================================================
164     /** @return int the course id. */
165     public function get_courseid() {
166         return $this->course->id;
167     }
169     /** @return object the row of the course table. */
170     public function get_course() {
171         return $this->course;
172     }
174     /** @return int the quiz id. */
175     public function get_quizid() {
176         return $this->quiz->id;
177     }
179     /** @return object the row of the quiz table. */
180     public function get_quiz() {
181         return $this->quiz;
182     }
184     /** @return string the name of this quiz. */
185     public function get_quiz_name() {
186         return $this->quiz->name;
187     }
189     /** @return int the quiz navigation method. */
190     public function get_navigation_method() {
191         return $this->quiz->navmethod;
192     }
194     /** @return int the number of attempts allowed at this quiz (0 = infinite). */
195     public function get_num_attempts_allowed() {
196         return $this->quiz->attempts;
197     }
199     /** @return int the course_module id. */
200     public function get_cmid() {
201         return $this->cm->id;
202     }
204     /** @return object the course_module object. */
205     public function get_cm() {
206         return $this->cm;
207     }
209     /** @return object the module context for this quiz. */
210     public function get_context() {
211         return $this->context;
212     }
214     /**
215      * @return bool wether the current user is someone who previews the quiz,
216      * rather than attempting it.
217      */
218     public function is_preview_user() {
219         if (is_null($this->ispreviewuser)) {
220             $this->ispreviewuser = has_capability('mod/quiz:preview', $this->context);
221         }
222         return $this->ispreviewuser;
223     }
225     /**
226      * @return whether any questions have been added to this quiz.
227      */
228     public function has_questions() {
229         return !empty($this->questionids);
230     }
232     /**
233      * @param int $id the question id.
234      * @return object the question object with that id.
235      */
236     public function get_question($id) {
237         return $this->questions[$id];
238     }
240     /**
241      * @param array $questionids question ids of the questions to load. null for all.
242      */
243     public function get_questions($questionids = null) {
244         if (is_null($questionids)) {
245             $questionids = $this->questionids;
246         }
247         $questions = array();
248         foreach ($questionids as $id) {
249             if (!array_key_exists($id, $this->questions)) {
250                 throw new moodle_exception('cannotstartmissingquestion', 'quiz', $this->view_url());
251             }
252             $questions[$id] = $this->questions[$id];
253             $this->ensure_question_loaded($id);
254         }
255         return $questions;
256     }
258     /**
259      * @param int $timenow the current time as a unix timestamp.
260      * @return quiz_access_manager and instance of the quiz_access_manager class
261      *      for this quiz at this time.
262      */
263     public function get_access_manager($timenow) {
264         if (is_null($this->accessmanager)) {
265             $this->accessmanager = new quiz_access_manager($this, $timenow,
266                     has_capability('mod/quiz:ignoretimelimits', $this->context, null, false));
267         }
268         return $this->accessmanager;
269     }
271     /**
272      * Wrapper round the has_capability funciton that automatically passes in the quiz context.
273      */
274     public function has_capability($capability, $userid = null, $doanything = true) {
275         return has_capability($capability, $this->context, $userid, $doanything);
276     }
278     /**
279      * Wrapper round the require_capability funciton that automatically passes in the quiz context.
280      */
281     public function require_capability($capability, $userid = null, $doanything = true) {
282         return require_capability($capability, $this->context, $userid, $doanything);
283     }
285     // URLs related to this attempt ============================================
286     /**
287      * @return string the URL of this quiz's view page.
288      */
289     public function view_url() {
290         global $CFG;
291         return $CFG->wwwroot . '/mod/quiz/view.php?id=' . $this->cm->id;
292     }
294     /**
295      * @return string the URL of this quiz's edit page.
296      */
297     public function edit_url() {
298         global $CFG;
299         return $CFG->wwwroot . '/mod/quiz/edit.php?cmid=' . $this->cm->id;
300     }
302     /**
303      * @param int $attemptid the id of an attempt.
304      * @param int $page optional page number to go to in the attempt.
305      * @return string the URL of that attempt.
306      */
307     public function attempt_url($attemptid, $page = 0) {
308         global $CFG;
309         $url = $CFG->wwwroot . '/mod/quiz/attempt.php?attempt=' . $attemptid;
310         if ($page) {
311             $url .= '&page=' . $page;
312         }
313         return $url;
314     }
316     /**
317      * @return string the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter.
318      */
319     public function start_attempt_url($page = 0) {
320         $params = array('cmid' => $this->cm->id, 'sesskey' => sesskey());
321         if ($page) {
322             $params['page'] = $page;
323         }
324         return new moodle_url('/mod/quiz/startattempt.php', $params);
325     }
327     /**
328      * @param int $attemptid the id of an attempt.
329      * @return string the URL of the review of that attempt.
330      */
331     public function review_url($attemptid) {
332         return new moodle_url('/mod/quiz/review.php', array('attempt' => $attemptid));
333     }
335     /**
336      * @param int $attemptid the id of an attempt.
337      * @return string the URL of the review of that attempt.
338      */
339     public function summary_url($attemptid) {
340         return new moodle_url('/mod/quiz/summary.php', array('attempt' => $attemptid));
341     }
343     // Bits of content =========================================================
345     /**
346      * @param bool $unfinished whether there is currently an unfinished attempt active.
347      * @return string if the quiz policies merit it, return a warning string to
348      *      be displayed in a javascript alert on the start attempt button.
349      */
350     public function confirm_start_attempt_message($unfinished) {
351         if ($unfinished) {
352             return '';
353         }
355         if ($this->quiz->timelimit && $this->quiz->attempts) {
356             return get_string('confirmstartattempttimelimit', 'quiz', $this->quiz->attempts);
357         } else if ($this->quiz->timelimit) {
358             return get_string('confirmstarttimelimit', 'quiz');
359         } else if ($this->quiz->attempts) {
360             return get_string('confirmstartattemptlimit', 'quiz', $this->quiz->attempts);
361         }
363         return '';
364     }
366     /**
367      * If $reviewoptions->attempt is false, meaning that students can't review this
368      * attempt at the moment, return an appropriate string explaining why.
369      *
370      * @param int $when One of the mod_quiz_display_options::DURING,
371      *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
372      * @param bool $short if true, return a shorter string.
373      * @return string an appropraite message.
374      */
375     public function cannot_review_message($when, $short = false) {
377         if ($short) {
378             $langstrsuffix = 'short';
379             $dateformat = get_string('strftimedatetimeshort', 'langconfig');
380         } else {
381             $langstrsuffix = '';
382             $dateformat = '';
383         }
385         if ($when == mod_quiz_display_options::DURING ||
386                 $when == mod_quiz_display_options::IMMEDIATELY_AFTER) {
387             return '';
388         } else if ($when == mod_quiz_display_options::LATER_WHILE_OPEN && $this->quiz->timeclose &&
389                 $this->quiz->reviewattempt & mod_quiz_display_options::AFTER_CLOSE) {
390             return get_string('noreviewuntil' . $langstrsuffix, 'quiz',
391                     userdate($this->quiz->timeclose, $dateformat));
392         } else {
393             return get_string('noreview' . $langstrsuffix, 'quiz');
394         }
395     }
397     /**
398      * @param string $title the name of this particular quiz page.
399      * @return array the data that needs to be sent to print_header_simple as the $navigation
400      * parameter.
401      */
402     public function navigation($title) {
403         global $PAGE;
404         $PAGE->navbar->add($title);
405         return '';
406     }
408     // Private methods =========================================================
409     /**
410      * Check that the definition of a particular question is loaded, and if not throw an exception.
411      * @param $id a questionid.
412      */
413     protected function ensure_question_loaded($id) {
414         if (isset($this->questions[$id]->_partiallyloaded)) {
415             throw new moodle_quiz_exception($this, 'questionnotloaded', $id);
416         }
417     }
421 /**
422  * This class extends the quiz class to hold data about the state of a particular attempt,
423  * in addition to the data about the quiz.
424  *
425  * @copyright  2008 Tim Hunt
426  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
427  * @since      Moodle 2.0
428  */
429 class quiz_attempt {
431     /** @var string to identify the in progress state. */
432     const IN_PROGRESS = 'inprogress';
433     /** @var string to identify the overdue state. */
434     const OVERDUE     = 'overdue';
435     /** @var string to identify the finished state. */
436     const FINISHED    = 'finished';
437     /** @var string to identify the abandoned state. */
438     const ABANDONED   = 'abandoned';
440     // Basic data.
441     protected $quizobj;
442     protected $attempt;
444     /** @var question_usage_by_activity the question usage for this quiz attempt. */
445     protected $quba;
447     /** @var array page no => array of slot numbers on the page in order. */
448     protected $pagelayout;
450     /** @var array slot => displayed question number for this slot. (E.g. 1, 2, 3 or 'i'.) */
451     protected $questionnumbers;
453     /** @var array slot => page number for this slot. */
454     protected $questionpages;
456     /** @var mod_quiz_display_options cache for the appropriate review options. */
457     protected $reviewoptions = null;
459     // Constructor =============================================================
460     /**
461      * Constructor assuming we already have the necessary data loaded.
462      *
463      * @param object $attempt the row of the quiz_attempts table.
464      * @param object $quiz the quiz object for this attempt and user.
465      * @param object $cm the course_module object for this quiz.
466      * @param object $course the row from the course table for the course we belong to.
467      * @param bool $loadquestions (optional) if true, the default, load all the details
468      *      of the state of each question. Else just set up the basic details of the attempt.
469      */
470     public function __construct($attempt, $quiz, $cm, $course, $loadquestions = true) {
471         $this->attempt = $attempt;
472         $this->quizobj = new quiz($quiz, $cm, $course);
474         if (!$loadquestions) {
475             return;
476         }
478         $this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid);
479         $this->determine_layout();
480         $this->number_questions();
481     }
483     /**
484      * Used by {create()} and {create_from_usage_id()}.
485      * @param array $conditions passed to $DB->get_record('quiz_attempts', $conditions).
486      */
487     protected static function create_helper($conditions) {
488         global $DB;
490         $attempt = $DB->get_record('quiz_attempts', $conditions, '*', MUST_EXIST);
491         $quiz = quiz_access_manager::load_quiz_and_settings($attempt->quiz);
492         $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
493         $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST);
495         // Update quiz with override information.
496         $quiz = quiz_update_effective_access($quiz, $attempt->userid);
498         return new quiz_attempt($attempt, $quiz, $cm, $course);
499     }
501     /**
502      * Static function to create a new quiz_attempt object given an attemptid.
503      *
504      * @param int $attemptid the attempt id.
505      * @return quiz_attempt the new quiz_attempt object
506      */
507     public static function create($attemptid) {
508         return self::create_helper(array('id' => $attemptid));
509     }
511     /**
512      * Static function to create a new quiz_attempt object given a usage id.
513      *
514      * @param int $usageid the attempt usage id.
515      * @return quiz_attempt the new quiz_attempt object
516      */
517     public static function create_from_usage_id($usageid) {
518         return self::create_helper(array('uniqueid' => $usageid));
519     }
521     /**
522      * @param string $state one of the state constants like IN_PROGRESS.
523      * @return string the human-readable state name.
524      */
525     public static function state_name($state) {
526         return quiz_attempt_state_name($state);
527     }
529     private function determine_layout() {
530         $this->pagelayout = array();
532         // Break up the layout string into pages.
533         $pagelayouts = explode(',0', quiz_clean_layout($this->attempt->layout, true));
535         // Strip off any empty last page (normally there is one).
536         if (end($pagelayouts) == '') {
537             array_pop($pagelayouts);
538         }
540         // File the ids into the arrays.
541         $this->pagelayout = array();
542         foreach ($pagelayouts as $page => $pagelayout) {
543             $pagelayout = trim($pagelayout, ',');
544             if ($pagelayout == '') {
545                 continue;
546             }
547             $this->pagelayout[$page] = explode(',', $pagelayout);
548         }
549     }
551     // Number the questions.
552     private function number_questions() {
553         $number = 1;
554         foreach ($this->pagelayout as $page => $slots) {
555             foreach ($slots as $slot) {
556                 $question = $this->quba->get_question($slot);
557                 if ($question->length > 0) {
558                     $this->questionnumbers[$slot] = $number;
559                     $number += $question->length;
560                 } else {
561                     $this->questionnumbers[$slot] = get_string('infoshort', 'quiz');
562                 }
563                 $this->questionpages[$slot] = $page;
564             }
565         }
566     }
568     /**
569      * If the given page number is out of range (before the first page, or after
570      * the last page, chnage it to be within range).
571      * @param int $page the requested page number.
572      * @return int a safe page number to use.
573      */
574     public function force_page_number_into_range($page) {
575         return min(max($page, 0), count($this->pagelayout) - 1);
576     }
578     // Simple getters ==========================================================
579     public function get_quiz() {
580         return $this->quizobj->get_quiz();
581     }
583     public function get_quizobj() {
584         return $this->quizobj;
585     }
587     /** @return int the course id. */
588     public function get_courseid() {
589         return $this->quizobj->get_courseid();
590     }
592     /** @return int the course id. */
593     public function get_course() {
594         return $this->quizobj->get_course();
595     }
597     /** @return int the quiz id. */
598     public function get_quizid() {
599         return $this->quizobj->get_quizid();
600     }
602     /** @return string the name of this quiz. */
603     public function get_quiz_name() {
604         return $this->quizobj->get_quiz_name();
605     }
607     /** @return int the quiz navigation method. */
608     public function get_navigation_method() {
609         return $this->quizobj->get_navigation_method();
610     }
612     /** @return object the course_module object. */
613     public function get_cm() {
614         return $this->quizobj->get_cm();
615     }
617     /** @return object the course_module object. */
618     public function get_cmid() {
619         return $this->quizobj->get_cmid();
620     }
622     /**
623      * @return bool wether the current user is someone who previews the quiz,
624      * rather than attempting it.
625      */
626     public function is_preview_user() {
627         return $this->quizobj->is_preview_user();
628     }
630     /** @return int the number of attempts allowed at this quiz (0 = infinite). */
631     public function get_num_attempts_allowed() {
632         return $this->quizobj->get_num_attempts_allowed();
633     }
635     /** @return int number fo pages in this quiz. */
636     public function get_num_pages() {
637         return count($this->pagelayout);
638     }
640     /**
641      * @param int $timenow the current time as a unix timestamp.
642      * @return quiz_access_manager and instance of the quiz_access_manager class
643      *      for this quiz at this time.
644      */
645     public function get_access_manager($timenow) {
646         return $this->quizobj->get_access_manager($timenow);
647     }
649     /** @return int the attempt id. */
650     public function get_attemptid() {
651         return $this->attempt->id;
652     }
654     /** @return int the attempt unique id. */
655     public function get_uniqueid() {
656         return $this->attempt->uniqueid;
657     }
659     /** @return object the row from the quiz_attempts table. */
660     public function get_attempt() {
661         return $this->attempt;
662     }
664     /** @return int the number of this attemp (is it this user's first, second, ... attempt). */
665     public function get_attempt_number() {
666         return $this->attempt->attempt;
667     }
669     /** @return string one of the quiz_attempt::IN_PROGRESS, FINISHED, OVERDUE or ABANDONED constants. */
670     public function get_state() {
671         return $this->attempt->state;
672     }
674     /** @return int the id of the user this attempt belongs to. */
675     public function get_userid() {
676         return $this->attempt->userid;
677     }
679     /** @return int the current page of the attempt. */
680     public function get_currentpage() {
681         return $this->attempt->currentpage;
682     }
684     public function get_sum_marks() {
685         return $this->attempt->sumgrades;
686     }
688     /**
689      * @return bool whether this attempt has been finished (true) or is still
690      *     in progress (false). Be warned that this is not just state == self::FINISHED,
691      *     it also includes self::ABANDONED.
692      */
693     public function is_finished() {
694         return $this->attempt->state == self::FINISHED || $this->attempt->state == self::ABANDONED;
695     }
697     /** @return bool whether this attempt is a preview attempt. */
698     public function is_preview() {
699         return $this->attempt->preview;
700     }
702     /**
703      * Is this a student dealing with their own attempt/teacher previewing,
704      * or someone with 'mod/quiz:viewreports' reviewing someone elses attempt.
705      *
706      * @return bool whether this situation should be treated as someone looking at their own
707      * attempt. The distinction normally only matters when an attempt is being reviewed.
708      */
709     public function is_own_attempt() {
710         global $USER;
711         return $this->attempt->userid == $USER->id &&
712                 (!$this->is_preview_user() || $this->attempt->preview);
713     }
715     /**
716      * @return bool whether this attempt is a preview belonging to the current user.
717      */
718     public function is_own_preview() {
719         global $USER;
720         return $this->attempt->userid == $USER->id &&
721                 $this->is_preview_user() && $this->attempt->preview;
722     }
724     /**
725      * Is the current user allowed to review this attempt. This applies when
726      * {@link is_own_attempt()} returns false.
727      * @return bool whether the review should be allowed.
728      */
729     public function is_review_allowed() {
730         if (!$this->has_capability('mod/quiz:viewreports')) {
731             return false;
732         }
734         $cm = $this->get_cm();
735         if ($this->has_capability('moodle/site:accessallgroups') ||
736                 groups_get_activity_groupmode($cm) != SEPARATEGROUPS) {
737             return true;
738         }
740         // Check the users have at least one group in common.
741         $teachersgroups = groups_get_activity_allowed_groups($cm);
742         $studentsgroups = groups_get_all_groups(
743                 $cm->course, $this->attempt->userid, $cm->groupingid);
744         return $teachersgroups && $studentsgroups &&
745                 array_intersect(array_keys($teachersgroups), array_keys($studentsgroups));
746     }
748     /**
749      * Get the overall feedback corresponding to a particular mark.
750      * @param $grade a particular grade.
751      */
752     public function get_overall_feedback($grade) {
753         return quiz_feedback_for_grade($grade, $this->get_quiz(),
754                 $this->quizobj->get_context());
755     }
757     /**
758      * Wrapper round the has_capability funciton that automatically passes in the quiz context.
759      */
760     public function has_capability($capability, $userid = null, $doanything = true) {
761         return $this->quizobj->has_capability($capability, $userid, $doanything);
762     }
764     /**
765      * Wrapper round the require_capability funciton that automatically passes in the quiz context.
766      */
767     public function require_capability($capability, $userid = null, $doanything = true) {
768         return $this->quizobj->require_capability($capability, $userid, $doanything);
769     }
771     /**
772      * Check the appropriate capability to see whether this user may review their own attempt.
773      * If not, prints an error.
774      */
775     public function check_review_capability() {
776         if (!$this->has_capability('mod/quiz:viewreports')) {
777             if ($this->get_attempt_state() == mod_quiz_display_options::IMMEDIATELY_AFTER) {
778                 $this->require_capability('mod/quiz:attempt');
779             } else {
780                 $this->require_capability('mod/quiz:reviewmyattempts');
781             }
782         }
783     }
785     /**
786      * Checks whether a user may navigate to a particular slot
787      */
788     public function can_navigate_to($slot) {
789         switch ($this->get_navigation_method()) {
790             case QUIZ_NAVMETHOD_FREE:
791                 return true;
792                 break;
793             case QUIZ_NAVMETHOD_SEQ:
794                 return false;
795                 break;
796         }
797         return true;
798     }
800     /**
801      * @return int one of the mod_quiz_display_options::DURING,
802      *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
803      */
804     public function get_attempt_state() {
805         return quiz_attempt_state($this->get_quiz(), $this->attempt);
806     }
808     /**
809      * Wrapper that the correct mod_quiz_display_options for this quiz at the
810      * moment.
811      *
812      * @return question_display_options the render options for this user on this attempt.
813      */
814     public function get_display_options($reviewing) {
815         if ($reviewing) {
816             if (is_null($this->reviewoptions)) {
817                 $this->reviewoptions = quiz_get_review_options($this->get_quiz(),
818                         $this->attempt, $this->quizobj->get_context());
819             }
820             return $this->reviewoptions;
822         } else {
823             $options = mod_quiz_display_options::make_from_quiz($this->get_quiz(),
824                     mod_quiz_display_options::DURING);
825             $options->flags = quiz_get_flag_option($this->attempt, $this->quizobj->get_context());
826             return $options;
827         }
828     }
830     /**
831      * Wrapper that the correct mod_quiz_display_options for this quiz at the
832      * moment.
833      *
834      * @param bool $reviewing true for review page, else attempt page.
835      * @param int $slot which question is being displayed.
836      * @param moodle_url $thispageurl to return to after the editing form is
837      *      submitted or cancelled. If null, no edit link will be generated.
838      *
839      * @return question_display_options the render options for this user on this
840      *      attempt, with extra info to generate an edit link, if applicable.
841      */
842     public function get_display_options_with_edit_link($reviewing, $slot, $thispageurl) {
843         $options = clone($this->get_display_options($reviewing));
845         if (!$thispageurl) {
846             return $options;
847         }
849         if (!($reviewing || $this->is_preview())) {
850             return $options;
851         }
853         $question = $this->quba->get_question($slot);
854         if (!question_has_capability_on($question, 'edit', $question->category)) {
855             return $options;
856         }
858         $options->editquestionparams['cmid'] = $this->get_cmid();
859         $options->editquestionparams['returnurl'] = $thispageurl;
861         return $options;
862     }
864     /**
865      * @param int $page page number
866      * @return bool true if this is the last page of the quiz.
867      */
868     public function is_last_page($page) {
869         return $page == count($this->pagelayout) - 1;
870     }
872     /**
873      * Return the list of question ids for either a given page of the quiz, or for the
874      * whole quiz.
875      *
876      * @param mixed $page string 'all' or integer page number.
877      * @return array the reqested list of question ids.
878      */
879     public function get_slots($page = 'all') {
880         if ($page === 'all') {
881             $numbers = array();
882             foreach ($this->pagelayout as $numbersonpage) {
883                 $numbers = array_merge($numbers, $numbersonpage);
884             }
885             return $numbers;
886         } else {
887             return $this->pagelayout[$page];
888         }
889     }
891     /**
892      * Get the question_attempt object for a particular question in this attempt.
893      * @param int $slot the number used to identify this question within this attempt.
894      * @return question_attempt
895      */
896     public function get_question_attempt($slot) {
897         return $this->quba->get_question_attempt($slot);
898     }
900     /**
901      * Is a particular question in this attempt a real question, or something like a description.
902      * @param int $slot the number used to identify this question within this attempt.
903      * @return bool whether that question is a real question.
904      */
905     public function is_real_question($slot) {
906         return $this->quba->get_question($slot)->length != 0;
907     }
909     /**
910      * Is a particular question in this attempt a real question, or something like a description.
911      * @param int $slot the number used to identify this question within this attempt.
912      * @return bool whether that question is a real question.
913      */
914     public function is_question_flagged($slot) {
915         return $this->quba->get_question_attempt($slot)->is_flagged();
916     }
918     /**
919      * @param int $slot the number used to identify this question within this attempt.
920      * @return string the displayed question number for the question in this slot.
921      *      For example '1', '2', '3' or 'i'.
922      */
923     public function get_question_number($slot) {
924         return $this->questionnumbers[$slot];
925     }
927     /**
928      * @param int $slot the number used to identify this question within this attempt.
929      * @return int the page of the quiz this question appears on.
930      */
931     public function get_question_page($slot) {
932         return $this->questionpages[$slot];
933     }
935     /**
936      * Return the grade obtained on a particular question, if the user is permitted
937      * to see it. You must previously have called load_question_states to load the
938      * state data about this question.
939      *
940      * @param int $slot the number used to identify this question within this attempt.
941      * @return string the formatted grade, to the number of decimal places specified
942      *      by the quiz.
943      */
944     public function get_question_name($slot) {
945         return $this->quba->get_question($slot)->name;
946     }
948     /**
949      * Return the grade obtained on a particular question, if the user is permitted
950      * to see it. You must previously have called load_question_states to load the
951      * state data about this question.
952      *
953      * @param int $slot the number used to identify this question within this attempt.
954      * @param bool $showcorrectness Whether right/partial/wrong states should
955      * be distinguised.
956      * @return string the formatted grade, to the number of decimal places specified
957      *      by the quiz.
958      */
959     public function get_question_status($slot, $showcorrectness) {
960         return $this->quba->get_question_state_string($slot, $showcorrectness);
961     }
963     /**
964      * Return the grade obtained on a particular question, if the user is permitted
965      * to see it. You must previously have called load_question_states to load the
966      * state data about this question.
967      *
968      * @param int $slot the number used to identify this question within this attempt.
969      * @param bool $showcorrectness Whether right/partial/wrong states should
970      * be distinguised.
971      * @return string class name for this state.
972      */
973     public function get_question_state_class($slot, $showcorrectness) {
974         return $this->quba->get_question_state_class($slot, $showcorrectness);
975     }
977     /**
978      * Return the grade obtained on a particular question.
979      * You must previously have called load_question_states to load the state
980      * data about this question.
981      *
982      * @param int $slot the number used to identify this question within this attempt.
983      * @return string the formatted grade, to the number of decimal places specified by the quiz.
984      */
985     public function get_question_mark($slot) {
986         return quiz_format_question_grade($this->get_quiz(), $this->quba->get_question_mark($slot));
987     }
989     /**
990      * Get the time of the most recent action performed on a question.
991      * @param int $slot the number used to identify this question within this usage.
992      * @return int timestamp.
993      */
994     public function get_question_action_time($slot) {
995         return $this->quba->get_question_action_time($slot);
996     }
998     /**
999      * Get the time remaining for an in-progress attempt, if the time is short
1000      * enought that it would be worth showing a timer.
1001      * @param int $timenow the time to consider as 'now'.
1002      * @return int|false the number of seconds remaining for this attempt.
1003      *      False if there is no limit.
1004      */
1005     public function get_time_left_display($timenow) {
1006         if ($this->attempt->state != self::IN_PROGRESS) {
1007             return false;
1008         }
1009         return $this->get_access_manager($timenow)->get_time_left_display($this->attempt, $timenow);
1010     }
1013     /**
1014      * @return int the time when this attempt was submitted. 0 if it has not been
1015      * submitted yet.
1016      */
1017     public function get_submitted_date() {
1018         return $this->attempt->timefinish;
1019     }
1021     /**
1022      * If the attempt is in an applicable state, work out the time by which the
1023      * student should next do something.
1024      * @return int timestamp by which the student needs to do something.
1025      */
1026     public function get_due_date() {
1027         $deadlines = array();
1028         if ($this->quizobj->get_quiz()->timelimit) {
1029             $deadlines[] = $this->attempt->timestart + $this->quizobj->get_quiz()->timelimit;
1030         }
1031         if ($this->quizobj->get_quiz()->timeclose) {
1032             $deadlines[] = $this->quizobj->get_quiz()->timeclose;
1033         }
1034         if ($deadlines) {
1035             $duedate = min($deadlines);
1036         } else {
1037             return false;
1038         }
1040         switch ($this->attempt->state) {
1041             case self::IN_PROGRESS:
1042                 return $duedate;
1044             case self::OVERDUE:
1045                 return $duedate + $this->quizobj->get_quiz()->graceperiod;
1047             default:
1048                 throw new coding_exception('Unexpected state: ' . $this->attempt->state);
1049         }
1050     }
1052     // URLs related to this attempt ============================================
1053     /**
1054      * @return string quiz view url.
1055      */
1056     public function view_url() {
1057         return $this->quizobj->view_url();
1058     }
1060     /**
1061      * @return string the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter.
1062      */
1063     public function start_attempt_url($slot = null, $page = -1) {
1064         if ($page == -1 && !is_null($slot)) {
1065             $page = $this->get_question_page($slot);
1066         } else {
1067             $page = 0;
1068         }
1069         return $this->quizobj->start_attempt_url($page);
1070     }
1072     /**
1073      * @param int $slot if speified, the slot number of a specific question to link to.
1074      * @param int $page if specified, a particular page to link to. If not givem deduced
1075      *      from $slot, or goes to the first page.
1076      * @param int $questionid a question id. If set, will add a fragment to the URL
1077      * to jump to a particuar question on the page.
1078      * @param int $thispage if not -1, the current page. Will cause links to other things on
1079      * this page to be output as only a fragment.
1080      * @return string the URL to continue this attempt.
1081      */
1082     public function attempt_url($slot = null, $page = -1, $thispage = -1) {
1083         return $this->page_and_question_url('attempt', $slot, $page, false, $thispage);
1084     }
1086     /**
1087      * @return string the URL of this quiz's summary page.
1088      */
1089     public function summary_url() {
1090         return new moodle_url('/mod/quiz/summary.php', array('attempt' => $this->attempt->id));
1091     }
1093     /**
1094      * @return string the URL of this quiz's summary page.
1095      */
1096     public function processattempt_url() {
1097         return new moodle_url('/mod/quiz/processattempt.php');
1098     }
1100     /**
1101      * @param int $slot indicates which question to link to.
1102      * @param int $page if specified, the URL of this particular page of the attempt, otherwise
1103      * the URL will go to the first page.  If -1, deduce $page from $slot.
1104      * @param bool $showall if true, the URL will be to review the entire attempt on one page,
1105      * and $page will be ignored.
1106      * @param int $thispage if not -1, the current page. Will cause links to other things on
1107      * this page to be output as only a fragment.
1108      * @return string the URL to review this attempt.
1109      */
1110     public function review_url($slot = null, $page = -1, $showall = false, $thispage = -1) {
1111         return $this->page_and_question_url('review', $slot, $page, $showall, $thispage);
1112     }
1114     // Bits of content =========================================================
1116     /**
1117      * If $reviewoptions->attempt is false, meaning that students can't review this
1118      * attempt at the moment, return an appropriate string explaining why.
1119      *
1120      * @param bool $short if true, return a shorter string.
1121      * @return string an appropraite message.
1122      */
1123     public function cannot_review_message($short = false) {
1124         return $this->quizobj->cannot_review_message(
1125                 $this->get_attempt_state(), $short);
1126     }
1128     /**
1129      * Initialise the JS etc. required all the questions on a page.
1130      * @param mixed $page a page number, or 'all'.
1131      */
1132     public function get_html_head_contributions($page = 'all', $showall = false) {
1133         if ($showall) {
1134             $page = 'all';
1135         }
1136         $result = '';
1137         foreach ($this->get_slots($page) as $slot) {
1138             $result .= $this->quba->render_question_head_html($slot);
1139         }
1140         $result .= question_engine::initialise_js();
1141         return $result;
1142     }
1144     /**
1145      * Initialise the JS etc. required by one question.
1146      * @param int $questionid the question id.
1147      */
1148     public function get_question_html_head_contributions($slot) {
1149         return $this->quba->render_question_head_html($slot) .
1150                 question_engine::initialise_js();
1151     }
1153     /**
1154      * Print the HTML for the start new preview button, if the current user
1155      * is allowed to see one.
1156      */
1157     public function restart_preview_button() {
1158         global $OUTPUT;
1159         if ($this->is_preview() && $this->is_preview_user()) {
1160             return $OUTPUT->single_button(new moodle_url(
1161                     $this->start_attempt_url(), array('forcenew' => true)),
1162                     get_string('startnewpreview', 'quiz'));
1163         } else {
1164             return '';
1165         }
1166     }
1168     /**
1169      * Generate the HTML that displayes the question in its current state, with
1170      * the appropriate display options.
1171      *
1172      * @param int $id the id of a question in this quiz attempt.
1173      * @param bool $reviewing is the being printed on an attempt or a review page.
1174      * @param moodle_url $thispageurl the URL of the page this question is being printed on.
1175      * @return string HTML for the question in its current state.
1176      */
1177     public function render_question($slot, $reviewing, $thispageurl = null) {
1178         return $this->quba->render_question($slot,
1179                 $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl),
1180                 $this->get_question_number($slot));
1181     }
1183     /**
1184      * Like {@link render_question()} but displays the question at the past step
1185      * indicated by $seq, rather than showing the latest step.
1186      *
1187      * @param int $id the id of a question in this quiz attempt.
1188      * @param int $seq the seq number of the past state to display.
1189      * @param bool $reviewing is the being printed on an attempt or a review page.
1190      * @param string $thispageurl the URL of the page this question is being printed on.
1191      * @return string HTML for the question in its current state.
1192      */
1193     public function render_question_at_step($slot, $seq, $reviewing, $thispageurl = '') {
1194         return $this->quba->render_question_at_step($slot, $seq,
1195                 $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl),
1196                 $this->get_question_number($slot));
1197     }
1199     /**
1200      * Wrapper round print_question from lib/questionlib.php.
1201      *
1202      * @param int $id the id of a question in this quiz attempt.
1203      */
1204     public function render_question_for_commenting($slot) {
1205         $options = $this->get_display_options(true);
1206         $options->hide_all_feedback();
1207         $options->manualcomment = question_display_options::EDITABLE;
1208         return $this->quba->render_question($slot, $options,
1209                 $this->get_question_number($slot));
1210     }
1212     /**
1213      * Check wheter access should be allowed to a particular file.
1214      *
1215      * @param int $id the id of a question in this quiz attempt.
1216      * @param bool $reviewing is the being printed on an attempt or a review page.
1217      * @param string $thispageurl the URL of the page this question is being printed on.
1218      * @return string HTML for the question in its current state.
1219      */
1220     public function check_file_access($slot, $reviewing, $contextid, $component,
1221             $filearea, $args, $forcedownload) {
1222         return $this->quba->check_file_access($slot, $this->get_display_options($reviewing),
1223                 $component, $filearea, $args, $forcedownload);
1224     }
1226     /**
1227      * Get the navigation panel object for this attempt.
1228      *
1229      * @param $panelclass The type of panel, quiz_attempt_nav_panel or quiz_review_nav_panel
1230      * @param $page the current page number.
1231      * @param $showall whether we are showing the whole quiz on one page. (Used by review.php)
1232      * @return quiz_nav_panel_base the requested object.
1233      */
1234     public function get_navigation_panel(mod_quiz_renderer $output,
1235              $panelclass, $page, $showall = false) {
1236         $panel = new $panelclass($this, $this->get_display_options(true), $page, $showall);
1238         $bc = new block_contents();
1239         $bc->attributes['id'] = 'mod_quiz_navblock';
1240         $bc->title = get_string('quiznavigation', 'quiz');
1241         $bc->content = $output->navigation_panel($panel);
1242         return $bc;
1243     }
1245     /**
1246      * Given a URL containing attempt={this attempt id}, return an array of variant URLs
1247      * @param moodle_url $url a URL.
1248      * @return string HTML fragment. Comma-separated list of links to the other
1249      * attempts with the attempt number as the link text. The curent attempt is
1250      * included but is not a link.
1251      */
1252     public function links_to_other_attempts(moodle_url $url) {
1253         $attempts = quiz_get_user_attempts($this->get_quiz()->id, $this->attempt->userid, 'all');
1254         if (count($attempts) <= 1) {
1255             return false;
1256         }
1258         $links = new mod_quiz_links_to_other_attempts();
1259         foreach ($attempts as $at) {
1260             if ($at->id == $this->attempt->id) {
1261                 $links->links[$at->attempt] = null;
1262             } else {
1263                 $links->links[$at->attempt] = new moodle_url($url, array('attempt' => $at->id));
1264             }
1265         }
1266         return $links;
1267     }
1269     // Methods for processing ==================================================
1271     /**
1272      * Check this attempt, to see if there are any state transitions that should
1273      * happen automatically.  This function will update the attempt checkstatetime.
1274      * @param int $timestamp the timestamp that should be stored as the modifed
1275      * @param bool $studentisonline is the student currently interacting with Moodle?
1276      */
1277     public function handle_if_time_expired($timestamp, $studentisonline) {
1278         global $DB;
1280         $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt);
1282         if ($timeclose === false || $this->is_preview()) {
1283             $this->update_timecheckstate(null);
1284             return; // No time limit
1285         }
1286         if ($timestamp < $timeclose) {
1287             $this->update_timecheckstate($timeclose);
1288             return; // Time has not yet expired.
1289         }
1291         // If the attempt is already overdue, look to see if it should be abandoned ...
1292         if ($this->attempt->state == self::OVERDUE) {
1293             $timeoverdue = $timestamp - $timeclose;
1294             $graceperiod = $this->quizobj->get_quiz()->graceperiod;
1295             if ($timeoverdue >= $graceperiod) {
1296                 $this->process_abandon($timestamp, $studentisonline);
1297             } else {
1298                 // Overdue time has not yet expired
1299                 $this->update_timecheckstate($timeclose + $graceperiod);
1300             }
1301             return; // ... and we are done.
1302         }
1304         if ($this->attempt->state != self::IN_PROGRESS) {
1305             $this->update_timecheckstate(null);
1306             return; // Attempt is already in a final state.
1307         }
1309         // Otherwise, we were in quiz_attempt::IN_PROGRESS, and time has now expired.
1310         // Transition to the appropriate state.
1311         switch ($this->quizobj->get_quiz()->overduehandling) {
1312             case 'autosubmit':
1313                 $this->process_finish($timestamp, false);
1314                 return;
1316             case 'graceperiod':
1317                 $this->process_going_overdue($timestamp, $studentisonline);
1318                 return;
1320             case 'autoabandon':
1321                 $this->process_abandon($timestamp, $studentisonline);
1322                 return;
1323         }
1325         // This is an overdue attempt with no overdue handling defined, so just abandon.
1326         $this->process_abandon($timestamp, $studentisonline);
1327         return;
1328     }
1330     /**
1331      * Process all the actions that were submitted as part of the current request.
1332      *
1333      * @param int $timestamp the timestamp that should be stored as the modifed
1334      * time in the database for these actions. If null, will use the current time.
1335      */
1336     public function process_submitted_actions($timestamp, $becomingoverdue = false) {
1337         global $DB;
1339         $transaction = $DB->start_delegated_transaction();
1341         $this->quba->process_all_actions($timestamp);
1342         question_engine::save_questions_usage_by_activity($this->quba);
1344         $this->attempt->timemodified = $timestamp;
1345         if ($this->attempt->state == self::FINISHED) {
1346             $this->attempt->sumgrades = $this->quba->get_total_mark();
1347         }
1348         if ($becomingoverdue) {
1349             $this->process_going_overdue($timestamp, true);
1350         } else {
1351             $DB->update_record('quiz_attempts', $this->attempt);
1352         }
1354         if (!$this->is_preview() && $this->attempt->state == self::FINISHED) {
1355             quiz_save_best_grade($this->get_quiz(), $this->get_userid());
1356         }
1358         $transaction->allow_commit();
1359     }
1361     /**
1362      * Update the flagged state for all question_attempts in this usage, if their
1363      * flagged state was changed in the request.
1364      */
1365     public function save_question_flags() {
1366         global $DB;
1368         $transaction = $DB->start_delegated_transaction();
1369         $this->quba->update_question_flags();
1370         question_engine::save_questions_usage_by_activity($this->quba);
1371         $transaction->allow_commit();
1372     }
1374     public function process_finish($timestamp, $processsubmitted) {
1375         global $DB;
1377         $transaction = $DB->start_delegated_transaction();
1379         if ($processsubmitted) {
1380             $this->quba->process_all_actions($timestamp);
1381         }
1382         $this->quba->finish_all_questions($timestamp);
1384         question_engine::save_questions_usage_by_activity($this->quba);
1386         $this->attempt->timemodified = $timestamp;
1387         $this->attempt->timefinish = $timestamp;
1388         $this->attempt->sumgrades = $this->quba->get_total_mark();
1389         $this->attempt->state = self::FINISHED;
1390         $this->attempt->timecheckstate = null;
1391         $DB->update_record('quiz_attempts', $this->attempt);
1393         if (!$this->is_preview()) {
1394             quiz_save_best_grade($this->get_quiz(), $this->attempt->userid);
1396             // Trigger event.
1397             $this->fire_state_transition_event('quiz_attempt_submitted', $timestamp);
1399             // Tell any access rules that care that the attempt is over.
1400             $this->get_access_manager($timestamp)->current_attempt_finished();
1401         }
1403         $transaction->allow_commit();
1404     }
1406     /**
1407      * Update this attempt timecheckstate if necessary.
1408      * @param int|null the timecheckstate
1409      */
1410     public function update_timecheckstate($time) {
1411         global $DB;
1412         if ($this->attempt->timecheckstate !== $time) {
1413             $this->attempt->timecheckstate = $time;
1414             $DB->set_field('quiz_attempts', 'timecheckstate', $time, array('id'=>$this->attempt->id));
1415         }
1416     }
1418     /**
1419      * Mark this attempt as now overdue.
1420      * @param int $timestamp the time to deem as now.
1421      * @param bool $studentisonline is the student currently interacting with Moodle?
1422      */
1423     public function process_going_overdue($timestamp, $studentisonline) {
1424         global $DB;
1426         $transaction = $DB->start_delegated_transaction();
1427         $this->attempt->timemodified = $timestamp;
1428         $this->attempt->state = self::OVERDUE;
1429         // If we knew the attempt close time, we could compute when the graceperiod ends.
1430         // Instead we'll just fix it up through cron.
1431         $this->attempt->timecheckstate = $timestamp;
1432         $DB->update_record('quiz_attempts', $this->attempt);
1434         $this->fire_state_transition_event('quiz_attempt_overdue', $timestamp);
1436         $transaction->allow_commit();
1437     }
1439     /**
1440      * Mark this attempt as abandoned.
1441      * @param int $timestamp the time to deem as now.
1442      * @param bool $studentisonline is the student currently interacting with Moodle?
1443      */
1444     public function process_abandon($timestamp, $studentisonline) {
1445         global $DB;
1447         $transaction = $DB->start_delegated_transaction();
1448         $this->attempt->timemodified = $timestamp;
1449         $this->attempt->state = self::ABANDONED;
1450         $this->attempt->timecheckstate = null;
1451         $DB->update_record('quiz_attempts', $this->attempt);
1453         $this->fire_state_transition_event('quiz_attempt_abandoned', $timestamp);
1455         $transaction->allow_commit();
1456     }
1458     /**
1459      * Fire a state transition event.
1460      * @param string $event the type of event. Should be listed in db/events.php.
1461      * @param int $timestamp the timestamp to include in the event.
1462      */
1463     protected function fire_state_transition_event($event, $timestamp) {
1464         global $USER;
1466         // Trigger event.
1467         $eventdata = new stdClass();
1468         $eventdata->component   = 'mod_quiz';
1469         $eventdata->attemptid   = $this->attempt->id;
1470         $eventdata->timestamp   = $timestamp;
1471         $eventdata->userid      = $this->attempt->userid;
1472         $eventdata->quizid      = $this->get_quizid();
1473         $eventdata->cmid        = $this->get_cmid();
1474         $eventdata->courseid    = $this->get_courseid();
1476         // I don't think if (CLI_SCRIPT) is really the right logic here. The
1477         // question is really 'is $USER currently set to a real user', but I cannot
1478         // see standard Moodle function to answer that question. For example,
1479         // cron fakes $USER.
1480         if (CLI_SCRIPT) {
1481             $eventdata->submitterid = null;
1482         } else {
1483             $eventdata->submitterid = $USER->id;
1484         }
1486         if ($event == 'quiz_attempt_submitted') {
1487             // Backwards compatibility for this event type. $eventdata->timestamp is now preferred.
1488             $eventdata->timefinish = $timestamp;
1489         }
1491         events_trigger($event, $eventdata);
1492     }
1494     /**
1495      * Print the fields of the comment form for questions in this attempt.
1496      * @param $slot which question to output the fields for.
1497      * @param $prefix Prefix to add to all field names.
1498      */
1499     public function question_print_comment_fields($slot, $prefix) {
1500         // Work out a nice title.
1501         $student = get_record('user', 'id', $this->get_userid());
1502         $a = new object();
1503         $a->fullname = fullname($student, true);
1504         $a->attempt = $this->get_attempt_number();
1506         question_print_comment_fields($this->quba->get_question_attempt($slot),
1507                 $prefix, $this->get_display_options(true)->markdp,
1508                 get_string('gradingattempt', 'quiz_grading', $a));
1509     }
1511     // Private methods =========================================================
1513     /**
1514      * Get a URL for a particular question on a particular page of the quiz.
1515      * Used by {@link attempt_url()} and {@link review_url()}.
1516      *
1517      * @param string $script. Used in the URL like /mod/quiz/$script.php
1518      * @param int $slot identifies the specific question on the page to jump to.
1519      *      0 to just use the $page parameter.
1520      * @param int $page -1 to look up the page number from the slot, otherwise
1521      *      the page number to go to.
1522      * @param bool $showall if true, return a URL with showall=1, and not page number
1523      * @param int $thispage the page we are currently on. Links to questions on this
1524      *      page will just be a fragment #q123. -1 to disable this.
1525      * @return The requested URL.
1526      */
1527     protected function page_and_question_url($script, $slot, $page, $showall, $thispage) {
1528         // Fix up $page.
1529         if ($page == -1) {
1530             if (!is_null($slot) && !$showall) {
1531                 $page = $this->get_question_page($slot);
1532             } else {
1533                 $page = 0;
1534             }
1535         }
1537         if ($showall) {
1538             $page = 0;
1539         }
1541         // Add a fragment to scroll down to the question.
1542         $fragment = '';
1543         if (!is_null($slot)) {
1544             if ($slot == reset($this->pagelayout[$page])) {
1545                 // First question on page, go to top.
1546                 $fragment = '#';
1547             } else {
1548                 $fragment = '#q' . $slot;
1549             }
1550         }
1552         // Work out the correct start to the URL.
1553         if ($thispage == $page) {
1554             return new moodle_url($fragment);
1556         } else {
1557             $url = new moodle_url('/mod/quiz/' . $script . '.php' . $fragment,
1558                     array('attempt' => $this->attempt->id));
1559             if ($showall) {
1560                 $url->param('showall', 1);
1561             } else if ($page > 0) {
1562                 $url->param('page', $page);
1563             }
1564             return $url;
1565         }
1566     }
1570 /**
1571  * Represents a single link in the navigation panel.
1572  *
1573  * @copyright  2011 The Open University
1574  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1575  * @since      Moodle 2.1
1576  */
1577 class quiz_nav_question_button implements renderable {
1578     public $id;
1579     public $number;
1580     public $stateclass;
1581     public $statestring;
1582     public $currentpage;
1583     public $flagged;
1584     public $url;
1588 /**
1589  * Represents the navigation panel, and builds a {@link block_contents} to allow
1590  * it to be output.
1591  *
1592  * @copyright  2008 Tim Hunt
1593  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1594  * @since      Moodle 2.0
1595  */
1596 abstract class quiz_nav_panel_base {
1597     /** @var quiz_attempt */
1598     protected $attemptobj;
1599     /** @var question_display_options */
1600     protected $options;
1601     /** @var integer */
1602     protected $page;
1603     /** @var boolean */
1604     protected $showall;
1606     public function __construct(quiz_attempt $attemptobj,
1607             question_display_options $options, $page, $showall) {
1608         $this->attemptobj = $attemptobj;
1609         $this->options = $options;
1610         $this->page = $page;
1611         $this->showall = $showall;
1612     }
1614     public function get_question_buttons() {
1615         $buttons = array();
1616         foreach ($this->attemptobj->get_slots() as $slot) {
1617             $qa = $this->attemptobj->get_question_attempt($slot);
1618             $showcorrectness = $this->options->correctness && $qa->has_marks();
1620             $button = new quiz_nav_question_button();
1621             $button->id          = 'quiznavbutton' . $slot;
1622             $button->number      = $this->attemptobj->get_question_number($slot);
1623             $button->stateclass  = $qa->get_state_class($showcorrectness);
1624             $button->navmethod   = $this->attemptobj->get_navigation_method();
1625             if (!$showcorrectness && $button->stateclass == 'notanswered') {
1626                 $button->stateclass = 'complete';
1627             }
1628             $button->statestring = $this->get_state_string($qa, $showcorrectness);
1629             $button->currentpage = $this->attemptobj->get_question_page($slot) == $this->page;
1630             $button->flagged     = $qa->is_flagged();
1631             $button->url         = $this->get_question_url($slot);
1632             $buttons[] = $button;
1633         }
1635         return $buttons;
1636     }
1638     protected function get_state_string(question_attempt $qa, $showcorrectness) {
1639         if ($qa->get_question()->length > 0) {
1640             return $qa->get_state_string($showcorrectness);
1641         }
1643         // Special case handling for 'information' items.
1644         if ($qa->get_state() == question_state::$todo) {
1645             return get_string('notyetviewed', 'quiz');
1646         } else {
1647             return get_string('viewed', 'quiz');
1648         }
1649     }
1651     public function render_before_button_bits(mod_quiz_renderer $output) {
1652         return '';
1653     }
1655     abstract public function render_end_bits(mod_quiz_renderer $output);
1657     protected function render_restart_preview_link($output) {
1658         if (!$this->attemptobj->is_own_preview()) {
1659             return '';
1660         }
1661         return $output->restart_preview_button(new moodle_url(
1662                 $this->attemptobj->start_attempt_url(), array('forcenew' => true)));
1663     }
1665     protected abstract function get_question_url($slot);
1667     public function user_picture() {
1668         global $DB;
1670         if (!$this->attemptobj->get_quiz()->showuserpicture) {
1671             return null;
1672         }
1674         $user = $DB->get_record('user', array('id' => $this->attemptobj->get_userid()));
1675         $userpicture = new user_picture($user);
1676         $userpicture->courseid = $this->attemptobj->get_courseid();
1677         return $userpicture;
1678     }
1682 /**
1683  * Specialisation of {@link quiz_nav_panel_base} for the attempt quiz page.
1684  *
1685  * @copyright  2008 Tim Hunt
1686  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1687  * @since      Moodle 2.0
1688  */
1689 class quiz_attempt_nav_panel extends quiz_nav_panel_base {
1690     public function get_question_url($slot) {
1691         if ($this->attemptobj->can_navigate_to($slot)) {
1692             return $this->attemptobj->attempt_url($slot, -1, $this->page);
1693         } else {
1694             return null;
1695         }
1696     }
1698     public function render_before_button_bits(mod_quiz_renderer $output) {
1699         return html_writer::tag('div', get_string('navnojswarning', 'quiz'),
1700                 array('id' => 'quiznojswarning'));
1701     }
1703     public function render_end_bits(mod_quiz_renderer $output) {
1704         return html_writer::link($this->attemptobj->summary_url(),
1705                 get_string('endtest', 'quiz'), array('class' => 'endtestlink')) .
1706                 $output->countdown_timer($this->attemptobj, time()) .
1707                 $this->render_restart_preview_link($output);
1708     }
1712 /**
1713  * Specialisation of {@link quiz_nav_panel_base} for the review quiz page.
1714  *
1715  * @copyright  2008 Tim Hunt
1716  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1717  * @since      Moodle 2.0
1718  */
1719 class quiz_review_nav_panel extends quiz_nav_panel_base {
1720     public function get_question_url($slot) {
1721         return $this->attemptobj->review_url($slot, -1, $this->showall, $this->page);
1722     }
1724     public function render_end_bits(mod_quiz_renderer $output) {
1725         $html = '';
1726         if ($this->attemptobj->get_num_pages() > 1) {
1727             if ($this->showall) {
1728                 $html .= html_writer::link($this->attemptobj->review_url(null, 0, false),
1729                         get_string('showeachpage', 'quiz'));
1730             } else {
1731                 $html .= html_writer::link($this->attemptobj->review_url(null, 0, true),
1732                         get_string('showall', 'quiz'));
1733             }
1734         }
1735         $html .= $output->finish_review_link($this->attemptobj);
1736         $html .= $this->render_restart_preview_link($output);
1737         return $html;
1738     }