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/>.
16
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 */
27
28
29defined('MOODLE_INTERNAL') || die();
30
31
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 */
40class moodle_quiz_exception extends moodle_exception {
41    /**
42     * Constructor.
43     *
44     * @param quiz $quizobj the quiz the error relates to.
45     * @param string $errorcode The name of the string from error.php to print.
46     * @param mixed $a Extra words and phrases that might be required in the error string.
47     * @param string $link The url where the user will be prompted to continue.
48     *      If no url is provided the user will be directed to the site index page.
49     * @param string|null $debuginfo optional debugging information.
50     */
51    public function __construct($quizobj, $errorcode, $a = null, $link = '', $debuginfo = null) {
52        if (!$link) {
53            $link = $quizobj->view_url();
54        }
55        parent::__construct($errorcode, 'quiz', $link, $a, $debuginfo);
56    }
57}
58
59
60/**
61 * A class encapsulating a quiz and the questions it contains, and making the
62 * information available to scripts like view.php.
63 *
64 * Initially, it only loads a minimal amout of information about each question - loading
65 * extra information only when necessary or when asked. The class tracks which questions
66 * are loaded.
67 *
68 * @copyright  2008 Tim Hunt
69 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
70 * @since      Moodle 2.0
71 */
72class quiz {
73    /** @var stdClass the course settings from the database. */
74    protected $course;
75    /** @var stdClass the course_module settings from the database. */
76    protected $cm;
77    /** @var stdClass the quiz settings from the database. */
78    protected $quiz;
79    /** @var context the quiz context. */
80    protected $context;
81
82    /** @var stdClass[] of questions augmented with slot information. */
83    protected $questions = null;
84    /** @var stdClass[] of quiz_section rows. */
85    protected $sections = null;
86    /** @var quiz_access_manager the access manager for this quiz. */
87    protected $accessmanager = null;
88    /** @var bool whether the current user has capability mod/quiz:preview. */
89    protected $ispreviewuser = null;
90
91    // Constructor =============================================================
92    /**
93     * Constructor, assuming we already have the necessary data loaded.
94     *
95     * @param object $quiz the row from the quiz table.
96     * @param object $cm the course_module object for this quiz.
97     * @param object $course the row from the course table for the course we belong to.
98     * @param bool $getcontext intended for testing - stops the constructor getting the context.
99     */
100    public function __construct($quiz, $cm, $course, $getcontext = true) {
101        $this->quiz = $quiz;
102        $this->cm = $cm;
103        $this->quiz->cmid = $this->cm->id;
104        $this->course = $course;
105        if ($getcontext && !empty($cm->id)) {
106            $this->context = context_module::instance($cm->id);
107        }
108    }
109
110    /**
111     * Static function to create a new quiz object for a specific user.
112     *
113     * @param int $quizid the the quiz id.
114     * @param int|null $userid the the userid (optional). If passed, relevant overrides are applied.
115     * @return quiz the new quiz object.
116     */
117    public static function create($quizid, $userid = null) {
118        global $DB;
119
120        $quiz = quiz_access_manager::load_quiz_and_settings($quizid);
121        $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
122        $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST);
123
124        // Update quiz with override information.
125        if ($userid) {
126            $quiz = quiz_update_effective_access($quiz, $userid);
127        }
128
129        return new quiz($quiz, $cm, $course);
130    }
131
132    /**
133     * Create a {@link quiz_attempt} for an attempt at this quiz.
134     *
135     * @param object $attemptdata row from the quiz_attempts table.
136     * @return quiz_attempt the new quiz_attempt object.
137     */
138    public function create_attempt_object($attemptdata) {
139        return new quiz_attempt($attemptdata, $this->quiz, $this->cm, $this->course);
140    }
141
142    // Functions for loading more data =========================================
143
144    /**
145     * Load just basic information about all the questions in this quiz.
146     */
147    public function preload_questions() {
148        $this->questions = question_preload_questions(null,
149                'slot.maxmark, slot.id AS slotid, slot.slot, slot.page,
150                 slot.questioncategoryid AS randomfromcategory,
151                 slot.includingsubcategories AS randomincludingsubcategories',
152                '{quiz_slots} slot ON slot.quizid = :quizid AND q.id = slot.questionid',
153                array('quizid' => $this->quiz->id), 'slot.slot');
154    }
155
156    /**
157     * Fully load some or all of the questions for this quiz. You must call
158     * {@link preload_questions()} first.
159     *
160     * @param array|null $questionids question ids of the questions to load. null for all.
161     */
162    public function load_questions($questionids = null) {
163        if ($this->questions === null) {
164            throw new coding_exception('You must call preload_questions before calling load_questions.');
165        }
166        if (is_null($questionids)) {
167            $questionids = array_keys($this->questions);
168        }
169        $questionstoprocess = array();
170        foreach ($questionids as $id) {
171            if (array_key_exists($id, $this->questions)) {
172                $questionstoprocess[$id] = $this->questions[$id];
173            }
174        }
175        get_question_options($questionstoprocess);
176    }
177
178    /**
179     * Get an instance of the {@link \mod_quiz\structure} class for this quiz.
180     * @return \mod_quiz\structure describes the questions in the quiz.
181     */
182    public function get_structure() {
183        return \mod_quiz\structure::create_for_quiz($this);
184    }
185
186    // Simple getters ==========================================================
187    /**
188     * Get the id of the course this quiz belongs to.
189     *
190     * @return int the course id.
191     */
192    public function get_courseid() {
193        return $this->course->id;
194    }
195
196    /**
197     * Get the course settings object that this quiz belongs to.
198     *
199     * @return object the row of the course table.
200     */
201    public function get_course() {
202        return $this->course;
203    }
204
205    /**
206     * Get this quiz's id (in the quiz table).
207     *
208     * @return int the quiz id.
209     */
210    public function get_quizid() {
211        return $this->quiz->id;
212    }
213
214    /**
215     * Get the quiz settings object.
216     *
217     * @return stdClass the row of the quiz table.
218     */
219    public function get_quiz() {
220        return $this->quiz;
221    }
222
223    /**
224     * Get the quiz name.
225     *
226     * @return string the name of this quiz.
227     */
228    public function get_quiz_name() {
229        return $this->quiz->name;
230    }
231
232    /**
233     * Get the navigation method in use.
234     *
235     * @return int QUIZ_NAVMETHOD_FREE or QUIZ_NAVMETHOD_SEQ.
236     */
237    public function get_navigation_method() {
238        return $this->quiz->navmethod;
239    }
240
241    /** @return int the number of attempts allowed at this quiz (0 = infinite). */
242    public function get_num_attempts_allowed() {
243        return $this->quiz->attempts;
244    }
245
246    /**
247     * Get the course-module id for this quiz.
248     *
249     * @return int the course_module id.
250     */
251    public function get_cmid() {
252        return $this->cm->id;
253    }
254
255    /**
256     * Get the course-module object for this quiz.
257     *
258     * @return object the course_module object.
259     */
260    public function get_cm() {
261        return $this->cm;
262    }
263
264    /**
265     * Get the quiz context.
266     *
267     * @return context the module context for this quiz.
268     */
269    public function get_context() {
270        return $this->context;
271    }
272
273    /**
274     * @return bool wether the current user is someone who previews the quiz,
275     * rather than attempting it.
276     */
277    public function is_preview_user() {
278        if (is_null($this->ispreviewuser)) {
279            $this->ispreviewuser = has_capability('mod/quiz:preview', $this->context);
280        }
281        return $this->ispreviewuser;
282    }
283
284    /**
285     * Checks user enrollment in the current course.
286     *
287     * @param int $userid the id of the user to check.
288     * @return bool whether the user is enrolled.
289     */
290    public function is_participant($userid) {
291        return is_enrolled($this->get_context(), $userid, 'mod/quiz:attempt', $this->show_only_active_users());
292    }
293
294    /**
295     * Check is only active users in course should be shown.
296     *
297     * @return bool true if only active users should be shown.
298     */
299    public function show_only_active_users() {
300        return !has_capability('moodle/course:viewsuspendedusers', $this->get_context());
301    }
302
303    /**
304     * @return bool whether any questions have been added to this quiz.
305     */
306    public function has_questions() {
307        if ($this->questions === null) {
308            $this->preload_questions();
309        }
310        return !empty($this->questions);
311    }
312
313    /**
314     * @param int $id the question id.
315     * @return stdClass the question object with that id.
316     */
317    public function get_question($id) {
318        return $this->questions[$id];
319    }
320
321    /**
322     * @param array|null $questionids question ids of the questions to load. null for all.
323     * @return stdClass[] the question data objects.
324     */
325    public function get_questions($questionids = null) {
326        if (is_null($questionids)) {
327            $questionids = array_keys($this->questions);
328        }
329        $questions = array();
330        foreach ($questionids as $id) {
331            if (!array_key_exists($id, $this->questions)) {
332                throw new moodle_exception('cannotstartmissingquestion', 'quiz', $this->view_url());
333            }
334            $questions[$id] = $this->questions[$id];
335            $this->ensure_question_loaded($id);
336        }
337        return $questions;
338    }
339
340    /**
341     * Get all the sections in this quiz.
342     *
343     * @return array 0, 1, 2, ... => quiz_sections row from the database.
344     */
345    public function get_sections() {
346        global $DB;
347        if ($this->sections === null) {
348            $this->sections = array_values($DB->get_records('quiz_sections',
349                    array('quizid' => $this->get_quizid()), 'firstslot'));
350        }
351        return $this->sections;
352    }
353
354    /**
355     * Return quiz_access_manager and instance of the quiz_access_manager class
356     * for this quiz at this time.
357     *
358     * @param int $timenow the current time as a unix timestamp.
359     * @return quiz_access_manager and instance of the quiz_access_manager class
360     *      for this quiz at this time.
361     */
362    public function get_access_manager($timenow) {
363        if (is_null($this->accessmanager)) {
364            $this->accessmanager = new quiz_access_manager($this, $timenow,
365                    has_capability('mod/quiz:ignoretimelimits', $this->context, null, false));
366        }
367        return $this->accessmanager;
368    }
369
370    /**
371     * Wrapper round the has_capability funciton that automatically passes in the quiz context.
372     *
373     * @param string $capability the name of the capability to check. For example mod/quiz:view.
374     * @param int|null $userid A user id. By default (null) checks the permissions of the current user.
375     * @param bool $doanything If false, ignore effect of admin role assignment.
376     * @return boolean true if the user has this capability. Otherwise false.
377     */
378    public function has_capability($capability, $userid = null, $doanything = true) {
379        return has_capability($capability, $this->context, $userid, $doanything);
380    }
381
382    /**
383     * Wrapper round the require_capability function that automatically passes in the quiz context.
384     *
385     * @param string $capability the name of the capability to check. For example mod/quiz:view.
386     * @param int|null $userid A user id. By default (null) checks the permissions of the current user.
387     * @param bool $doanything If false, ignore effect of admin role assignment.
388     */
389    public function require_capability($capability, $userid = null, $doanything = true) {
390        require_capability($capability, $this->context, $userid, $doanything);
391    }
392
393    // URLs related to this attempt ============================================
394    /**
395     * @return string the URL of this quiz's view page.
396     */
397    public function view_url() {
398        global $CFG;
399        return $CFG->wwwroot . '/mod/quiz/view.php?id=' . $this->cm->id;
400    }
401
402    /**
403     * @return string the URL of this quiz's edit page.
404     */
405    public function edit_url() {
406        global $CFG;
407        return $CFG->wwwroot . '/mod/quiz/edit.php?cmid=' . $this->cm->id;
408    }
409
410    /**
411     * @param int $attemptid the id of an attempt.
412     * @param int $page optional page number to go to in the attempt.
413     * @return string the URL of that attempt.
414     */
415    public function attempt_url($attemptid, $page = 0) {
416        global $CFG;
417        $url = $CFG->wwwroot . '/mod/quiz/attempt.php?attempt=' . $attemptid;
418        if ($page) {
419            $url .= '&page=' . $page;
420        }
421        $url .= '&cmid=' . $this->get_cmid();
422        return $url;
423    }
424
425    /**
426     * Get the URL to start/continue an attempt.
427     *
428     * @param int $page page in the attempt to start on (optional).
429     * @return moodle_url the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter.
430     */
431    public function start_attempt_url($page = 0) {
432        $params = array('cmid' => $this->cm->id, 'sesskey' => sesskey());
433        if ($page) {
434            $params['page'] = $page;
435        }
436        return new moodle_url('/mod/quiz/startattempt.php', $params);
437    }
438
439    /**
440     * @param int $attemptid the id of an attempt.
441     * @return string the URL of the review of that attempt.
442     */
443    public function review_url($attemptid) {
444        return new moodle_url('/mod/quiz/review.php', array('attempt' => $attemptid, 'cmid' => $this->get_cmid()));
445    }
446
447    /**
448     * @param int $attemptid the id of an attempt.
449     * @return string the URL of the review of that attempt.
450     */
451    public function summary_url($attemptid) {
452        return new moodle_url('/mod/quiz/summary.php', array('attempt' => $attemptid, 'cmid' => $this->get_cmid()));
453    }
454
455    // Bits of content =========================================================
456
457    /**
458     * @param bool $notused not used.
459     * @return string an empty string.
460     * @deprecated since 3.1. This sort of functionality is now entirely handled by quiz access rules.
461     */
462    public function confirm_start_attempt_message($notused) {
463        debugging('confirm_start_attempt_message is deprecated. ' .
464                'This sort of functionality is now entirely handled by quiz access rules.');
465        return '';
466    }
467
468    /**
469     * If $reviewoptions->attempt is false, meaning that students can't review this
470     * attempt at the moment, return an appropriate string explaining why.
471     *
472     * @param int $when One of the mod_quiz_display_options::DURING,
473     *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
474     * @param bool $short if true, return a shorter string.
475     * @return string an appropraite message.
476     */
477    public function cannot_review_message($when, $short = false) {
478
479        if ($short) {
480            $langstrsuffix = 'short';
481            $dateformat = get_string('strftimedatetimeshort', 'langconfig');
482        } else {
483            $langstrsuffix = '';
484            $dateformat = '';
485        }
486
487        if ($when == mod_quiz_display_options::DURING ||
488                $when == mod_quiz_display_options::IMMEDIATELY_AFTER) {
489            return '';
490        } else if ($when == mod_quiz_display_options::LATER_WHILE_OPEN && $this->quiz->timeclose &&
491                $this->quiz->reviewattempt & mod_quiz_display_options::AFTER_CLOSE) {
492            return get_string('noreviewuntil' . $langstrsuffix, 'quiz',
493                    userdate($this->quiz->timeclose, $dateformat));
494        } else {
495            return get_string('noreview' . $langstrsuffix, 'quiz');
496        }
497    }
498
499    /**
500     * Probably not used any more, but left for backwards compatibility.
501     *
502     * @param string $title the name of this particular quiz page.
503     * @return string always returns ''.
504     */
505    public function navigation($title) {
506        global $PAGE;
507        $PAGE->navbar->add($title);
508        return '';
509    }
510
511    // Private methods =========================================================
512    /**
513     * Check that the definition of a particular question is loaded, and if not throw an exception.
514     *
515     * @param int $id a question id.
516     */
517    protected function ensure_question_loaded($id) {
518        if (isset($this->questions[$id]->_partiallyloaded)) {
519            throw new moodle_quiz_exception($this, 'questionnotloaded', $id);
520        }
521    }
522
523    /**
524     * Return all the question types used in this quiz.
525     *
526     * @param  boolean $includepotential if the quiz include random questions,
527     *      setting this flag to true will make the function to return all the
528     *      possible question types in the random questions category.
529     * @return array a sorted array including the different question types.
530     * @since  Moodle 3.1
531     */
532    public function get_all_question_types_used($includepotential = false) {
533        $questiontypes = array();
534
535        // To control if we need to look in categories for questions.
536        $qcategories = array();
537
538        // We must be careful with random questions, if we find a random question we must assume that the quiz may content
539        // any of the questions in the referenced category (or subcategories).
540        foreach ($this->get_questions() as $questiondata) {
541            if ($questiondata->qtype == 'random' and $includepotential) {
542                $includesubcategories = (bool) $questiondata->questiontext;
543                if (!isset($qcategories[$questiondata->category])) {
544                    $qcategories[$questiondata->category] = false;
545                }
546                if ($includesubcategories) {
547                    $qcategories[$questiondata->category] = true;
548                }
549            } else {
550                if (!in_array($questiondata->qtype, $questiontypes)) {
551                    $questiontypes[] = $questiondata->qtype;
552                }
553            }
554        }
555
556        if (!empty($qcategories)) {
557            // We have to look for all the question types in these categories.
558            $categoriestolook = array();
559            foreach ($qcategories as $cat => $includesubcats) {
560                if ($includesubcats) {
561                    $categoriestolook = array_merge($categoriestolook, question_categorylist($cat));
562                } else {
563                    $categoriestolook[] = $cat;
564                }
565            }
566            $questiontypesincategories = question_bank::get_all_question_types_in_categories($categoriestolook);
567            $questiontypes = array_merge($questiontypes, $questiontypesincategories);
568        }
569        $questiontypes = array_unique($questiontypes);
570        sort($questiontypes);
571
572        return $questiontypes;
573    }
574}
575
576
577/**
578 * This class extends the quiz class to hold data about the state of a particular attempt,
579 * in addition to the data about the quiz.
580 *
581 * @copyright  2008 Tim Hunt
582 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
583 * @since      Moodle 2.0
584 */
585class quiz_attempt {
586
587    /** @var string to identify the in progress state. */
588    const IN_PROGRESS = 'inprogress';
589    /** @var string to identify the overdue state. */
590    const OVERDUE     = 'overdue';
591    /** @var string to identify the finished state. */
592    const FINISHED    = 'finished';
593    /** @var string to identify the abandoned state. */
594    const ABANDONED   = 'abandoned';
595
596    /** @var int maximum number of slots in the quiz for the review page to default to show all. */
597    const MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL = 50;
598
599    /** @var quiz object containing the quiz settings. */
600    protected $quizobj;
601
602    /** @var stdClass the quiz_attempts row. */
603    protected $attempt;
604
605    /** @var question_usage_by_activity the question usage for this quiz attempt. */
606    protected $quba;
607
608    /**
609     * @var array of slot information. These objects contain ->slot (int),
610     *      ->requireprevious (bool), ->questionids (int) the original question for random questions,
611     *      ->firstinsection (bool), ->section (stdClass from $this->sections).
612     *      This does not contain page - get that from {@link get_question_page()} -
613     *      or maxmark - get that from $this->quba.
614     */
615    protected $slots;
616
617    /** @var array of quiz_sections rows, with a ->lastslot field added. */
618    protected $sections;
619
620    /** @var array page no => array of slot numbers on the page in order. */
621    protected $pagelayout;
622
623    /** @var array slot => displayed question number for this slot. (E.g. 1, 2, 3 or 'i'.) */
624    protected $questionnumbers;
625
626    /** @var array slot => page number for this slot. */
627    protected $questionpages;
628
629    /** @var mod_quiz_display_options cache for the appropriate review options. */
630    protected $reviewoptions = null;
631
632    // Constructor =============================================================
633    /**
634     * Constructor assuming we already have the necessary data loaded.
635     *
636     * @param object $attempt the row of the quiz_attempts table.
637     * @param object $quiz the quiz object for this attempt and user.
638     * @param object $cm the course_module object for this quiz.
639     * @param object $course the row from the course table for the course we belong to.
640     * @param bool $loadquestions (optional) if true, the default, load all the details
641     *      of the state of each question. Else just set up the basic details of the attempt.
642     */
643    public function __construct($attempt, $quiz, $cm, $course, $loadquestions = true) {
644        $this->attempt = $attempt;
645        $this->quizobj = new quiz($quiz, $cm, $course);
646
647        if ($loadquestions) {
648            $this->load_questions();
649        }
650    }
651
652    /**
653     * Used by {create()} and {create_from_usage_id()}.
654     *
655     * @param array $conditions passed to $DB->get_record('quiz_attempts', $conditions).
656     * @return quiz_attempt the desired instance of this class.
657     */
658    protected static function create_helper($conditions) {
659        global $DB;
660
661        $attempt = $DB->get_record('quiz_attempts', $conditions, '*', MUST_EXIST);
662        $quiz = quiz_access_manager::load_quiz_and_settings($attempt->quiz);
663        $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
664        $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST);
665
666        // Update quiz with override information.
667        $quiz = quiz_update_effective_access($quiz, $attempt->userid);
668
669        return new quiz_attempt($attempt, $quiz, $cm, $course);
670    }
671
672    /**
673     * Static function to create a new quiz_attempt object given an attemptid.
674     *
675     * @param int $attemptid the attempt id.
676     * @return quiz_attempt the new quiz_attempt object
677     */
678    public static function create($attemptid) {
679        return self::create_helper(array('id' => $attemptid));
680    }
681
682    /**
683     * Static function to create a new quiz_attempt object given a usage id.
684     *
685     * @param int $usageid the attempt usage id.
686     * @return quiz_attempt the new quiz_attempt object
687     */
688    public static function create_from_usage_id($usageid) {
689        return self::create_helper(array('uniqueid' => $usageid));
690    }
691
692    /**
693     * @param string $state one of the state constants like IN_PROGRESS.
694     * @return string the human-readable state name.
695     */
696    public static function state_name($state) {
697        return quiz_attempt_state_name($state);
698    }
699
700    /**
701     * This method can be called later if the object was constructed with $loadqusetions = false.
702     */
703    public function load_questions() {
704        global $DB;
705
706        if (isset($this->quba)) {
707            throw new coding_exception('This quiz attempt has already had the questions loaded.');
708        }
709
710        $this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid);
711        $this->slots = $DB->get_records('quiz_slots',
712                array('quizid' => $this->get_quizid()), 'slot',
713                'slot, id, requireprevious, questionid, includingsubcategories');
714        $this->sections = array_values($DB->get_records('quiz_sections',
715                array('quizid' => $this->get_quizid()), 'firstslot'));
716
717        $this->link_sections_and_slots();
718        $this->determine_layout();
719        $this->number_questions();
720    }
721
722    /**
723     * Let each slot know which section it is part of.
724     */
725    protected function link_sections_and_slots() {
726        foreach ($this->sections as $i => $section) {
727            if (isset($this->sections[$i + 1])) {
728                $section->lastslot = $this->sections[$i + 1]->firstslot - 1;
729            } else {
730                $section->lastslot = count($this->slots);
731            }
732            for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
733                $this->slots[$slot]->section = $section;
734            }
735        }
736    }
737
738    /**
739     * Parse attempt->layout to populate the other arrays the represent the layout.
740     */
741    protected function determine_layout() {
742        $this->pagelayout = array();
743
744        // Break up the layout string into pages.
745        $pagelayouts = explode(',0', $this->attempt->layout);
746
747        // Strip off any empty last page (normally there is one).
748        if (end($pagelayouts) == '') {
749            array_pop($pagelayouts);
750        }
751
752        // File the ids into the arrays.
753        // Tracking which is the first slot in each section in this attempt is
754        // trickier than you might guess, since the slots in this section
755        // may be shuffled, so $section->firstslot (the lowest numbered slot in
756        // the section) may not be the first one.
757        $unseensections = $this->sections;
758        $this->pagelayout = array();
759        foreach ($pagelayouts as $page => $pagelayout) {
760            $pagelayout = trim($pagelayout, ',');
761            if ($pagelayout == '') {
762                continue;
763            }
764            $this->pagelayout[$page] = explode(',', $pagelayout);
765            foreach ($this->pagelayout[$page] as $slot) {
766                $sectionkey = array_search($this->slots[$slot]->section, $unseensections);
767                if ($sectionkey !== false) {
768                    $this->slots[$slot]->firstinsection = true;
769                    unset($unseensections[$sectionkey]);
770                } else {
771                    $this->slots[$slot]->firstinsection = false;
772                }
773            }
774        }
775    }
776
777    /**
778     * Work out the number to display for each question/slot.
779     */
780    protected function number_questions() {
781        $number = 1;
782        foreach ($this->pagelayout as $page => $slots) {
783            foreach ($slots as $slot) {
784                if ($length = $this->is_real_question($slot)) {
785                    $this->questionnumbers[$slot] = $number;
786                    $number += $length;
787                } else {
788                    $this->questionnumbers[$slot] = get_string('infoshort', 'quiz');
789                }
790                $this->questionpages[$slot] = $page;
791            }
792        }
793    }
794
795    /**
796     * If the given page number is out of range (before the first page, or after
797     * the last page, chnage it to be within range).
798     *
799     * @param int $page the requested page number.
800     * @return int a safe page number to use.
801     */
802    public function force_page_number_into_range($page) {
803        return min(max($page, 0), count($this->pagelayout) - 1);
804    }
805
806    // Simple getters ==========================================================
807    public function get_quiz() {
808        return $this->quizobj->get_quiz();
809    }
810
811    public function get_quizobj() {
812        return $this->quizobj;
813    }
814
815    /** @return int the course id. */
816    public function get_courseid() {
817        return $this->quizobj->get_courseid();
818    }
819
820    /**
821     * Get the course settings object.
822     *
823     * @return stdClass the course settings object.
824     */
825    public function get_course() {
826        return $this->quizobj->get_course();
827    }
828
829    /** @return int the quiz id. */
830    public function get_quizid() {
831        return $this->quizobj->get_quizid();
832    }
833
834    /** @return string the name of this quiz. */
835    public function get_quiz_name() {
836        return $this->quizobj->get_quiz_name();
837    }
838
839    /** @return int the quiz navigation method. */
840    public function get_navigation_method() {
841        return $this->quizobj->get_navigation_method();
842    }
843
844    /** @return object the course_module object. */
845    public function get_cm() {
846        return $this->quizobj->get_cm();
847    }
848
849    /**
850     * Get the course-module id.
851     *
852     * @return int the course_module id.
853     */
854    public function get_cmid() {
855        return $this->quizobj->get_cmid();
856    }
857
858    /**
859     * @return bool whether the current user is someone who previews the quiz,
860     * rather than attempting it.
861     */
862    public function is_preview_user() {
863        return $this->quizobj->is_preview_user();
864    }
865
866    /** @return int the number of attempts allowed at this quiz (0 = infinite). */
867    public function get_num_attempts_allowed() {
868        return $this->quizobj->get_num_attempts_allowed();
869    }
870
871    /** @return int number fo pages in this quiz. */
872    public function get_num_pages() {
873        return count($this->pagelayout);
874    }
875
876    /**
877     * @param int $timenow the current time as a unix timestamp.
878     * @return quiz_access_manager and instance of the quiz_access_manager class
879     *      for this quiz at this time.
880     */
881    public function get_access_manager($timenow) {
882        return $this->quizobj->get_access_manager($timenow);
883    }
884
885    /** @return int the attempt id. */
886    public function get_attemptid() {
887        return $this->attempt->id;
888    }
889
890    /** @return int the attempt unique id. */
891    public function get_uniqueid() {
892        return $this->attempt->uniqueid;
893    }
894
895    /** @return object the row from the quiz_attempts table. */
896    public function get_attempt() {
897        return $this->attempt;
898    }
899
900    /** @return int the number of this attemp (is it this user's first, second, ... attempt). */
901    public function get_attempt_number() {
902        return $this->attempt->attempt;
903    }
904
905    /** @return string one of the quiz_attempt::IN_PROGRESS, FINISHED, OVERDUE or ABANDONED constants. */
906    public function get_state() {
907        return $this->attempt->state;
908    }
909
910    /** @return int the id of the user this attempt belongs to. */
911    public function get_userid() {
912        return $this->attempt->userid;
913    }
914
915    /** @return int the current page of the attempt. */
916    public function get_currentpage() {
917        return $this->attempt->currentpage;
918    }
919
920    public function get_sum_marks() {
921        return $this->attempt->sumgrades;
922    }
923
924    /**
925     * @return bool whether this attempt has been finished (true) or is still
926     *     in progress (false). Be warned that this is not just state == self::FINISHED,
927     *     it also includes self::ABANDONED.
928     */
929    public function is_finished() {
930        return $this->attempt->state == self::FINISHED || $this->attempt->state == self::ABANDONED;
931    }
932
933    /** @return bool whether this attempt is a preview attempt. */
934    public function is_preview() {
935        return $this->attempt->preview;
936    }
937
938    /**
939     * Is this someone dealing with their own attempt or preview?
940     *
941     * @return bool true => own attempt/preview. false => reviewing someone else's.
942     */
943    public function is_own_attempt() {
944        global $USER;
945        return $this->attempt->userid == $USER->id;
946    }
947
948    /**
949     * @return bool whether this attempt is a preview belonging to the current user.
950     */
951    public function is_own_preview() {
952        return $this->is_own_attempt() &&
953                $this->is_preview_user() && $this->attempt->preview;
954    }
955
956    /**
957     * Is the current user allowed to review this attempt. This applies when
958     * {@link is_own_attempt()} returns false.
959     *
960     * @return bool whether the review should be allowed.
961     */
962    public function is_review_allowed() {
963        if (!$this->has_capability('mod/quiz:viewreports')) {
964            return false;
965        }
966
967        $cm = $this->get_cm();
968        if ($this->has_capability('moodle/site:accessallgroups') ||
969                groups_get_activity_groupmode($cm) != SEPARATEGROUPS) {
970            return true;
971        }
972
973        // Check the users have at least one group in common.
974        $teachersgroups = groups_get_activity_allowed_groups($cm);
975        $studentsgroups = groups_get_all_groups(
976                $cm->course, $this->attempt->userid, $cm->groupingid);
977        return $teachersgroups && $studentsgroups &&
978                array_intersect(array_keys($teachersgroups), array_keys($studentsgroups));
979    }
980
981    /**
982     * Has the student, in this attempt, engaged with the quiz in a non-trivial way?
983     *
984     * That is, is there any question worth a non-zero number of marks, where
985     * the student has made some response that we have saved?
986     *
987     * @return bool true if we have saved a response for at least one graded question.
988     */
989    public function has_response_to_at_least_one_graded_question() {
990        foreach ($this->quba->get_attempt_iterator() as $qa) {
991            if ($qa->get_max_mark() == 0) {
992                continue;
993            }
994            if ($qa->get_num_steps() > 1) {
995                return true;
996            }
997        }
998        return false;
999    }
1000
1001    /**
1002     * Get extra summary information about this attempt.
1003     *
1004     * Some behaviours may be able to provide interesting summary information
1005     * about the attempt as a whole, and this method provides access to that data.
1006     * To see how this works, try setting a quiz to one of the CBM behaviours,
1007     * and then look at the extra information displayed at the top of the quiz
1008     * review page once you have sumitted an attempt.
1009     *
1010     * In the return value, the array keys are identifiers of the form
1011     * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary.
1012     * The values are arrays with two items, title and content. Each of these
1013     * will be either a string, or a renderable.
1014     *
1015     * @param question_display_options $options the display options for this quiz attempt at this time.
1016     * @return array as described above.
1017     */
1018    public function get_additional_summary_data(question_display_options $options) {
1019        return $this->quba->get_summary_information($options);
1020    }
1021
1022    /**
1023     * Get the overall feedback corresponding to a particular mark.
1024     *
1025     * @param number $grade a particular grade.
1026     * @return string the feedback.
1027     */
1028    public function get_overall_feedback($grade) {
1029        return quiz_feedback_for_grade($grade, $this->get_quiz(),
1030                $this->quizobj->get_context());
1031    }
1032
1033    /**
1034     * Wrapper round the has_capability funciton that automatically passes in the quiz context.
1035     *
1036     * @param string $capability the name of the capability to check. For example mod/forum:view.
1037     * @param int|null $userid A user id. By default (null) checks the permissions of the current user.
1038     * @param bool $doanything If false, ignore effect of admin role assignment.
1039     * @return boolean true if the user has this capability. Otherwise false.
1040     */
1041    public function has_capability($capability, $userid = null, $doanything = true) {
1042        return $this->quizobj->has_capability($capability, $userid, $doanything);
1043    }
1044
1045    /**
1046     * Wrapper round the require_capability function that automatically passes in the quiz context.
1047     *
1048     * @param string $capability the name of the capability to check. For example mod/forum:view.
1049     * @param int|null $userid A user id. By default (null) checks the permissions of the current user.
1050     * @param bool $doanything If false, ignore effect of admin role assignment.
1051     */
1052    public function require_capability($capability, $userid = null, $doanything = true) {
1053        $this->quizobj->require_capability($capability, $userid, $doanything);
1054    }
1055
1056    /**
1057     * Check the appropriate capability to see whether this user may review their own attempt.
1058     * If not, prints an error.
1059     */
1060    public function check_review_capability() {
1061        if ($this->get_attempt_state() == mod_quiz_display_options::IMMEDIATELY_AFTER) {
1062            $capability = 'mod/quiz:attempt';
1063        } else {
1064            $capability = 'mod/quiz:reviewmyattempts';
1065        }
1066
1067        // These next tests are in a slighly funny order. The point is that the
1068        // common and most performance-critical case is students attempting a quiz
1069        // so we want to check that permisison first.
1070
1071        if ($this->has_capability($capability)) {
1072            // User has the permission that lets you do the quiz as a student. Fine.
1073            return;
1074        }
1075
1076        if ($this->has_capability('mod/quiz:viewreports') ||
1077                $this->has_capability('mod/quiz:preview')) {
1078            // User has the permission that lets teachers review. Fine.
1079            return;
1080        }
1081
1082        // They should not be here. Trigger the standard no-permission error
1083        // but using the name of the student capability.
1084        // We know this will fail. We just want the stadard exception thown.
1085        $this->require_capability($capability);
1086    }
1087
1088    /**
1089     * Checks whether a user may navigate to a particular slot.
1090     *
1091     * @param int $slot the target slot (currently does not affect the answer).
1092     * @return bool true if the navigation should be allowed.
1093     */
1094    public function can_navigate_to($slot) {
1095        if ($this->attempt->state == self::OVERDUE) {
1096            // When the attempt is overdue, students can only see the
1097            // attempt summary page and cannot navigate anywhere else.
1098            return false;
1099        }
1100
1101        switch ($this->get_navigation_method()) {
1102            case QUIZ_NAVMETHOD_FREE:
1103                return true;
1104                break;
1105            case QUIZ_NAVMETHOD_SEQ:
1106                return false;
1107                break;
1108        }
1109        return true;
1110    }
1111
1112    /**
1113     * @return int one of the mod_quiz_display_options::DURING,
1114     *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
1115     */
1116    public function get_attempt_state() {
1117        return quiz_attempt_state($this->get_quiz(), $this->attempt);
1118    }
1119
1120    /**
1121     * Wrapper that the correct mod_quiz_display_options for this quiz at the
1122     * moment.
1123     *
1124     * @param bool $reviewing true for options when reviewing, false for when attempting.
1125     * @return question_display_options the render options for this user on this attempt.
1126     */
1127    public function get_display_options($reviewing) {
1128        if ($reviewing) {
1129            if (is_null($this->reviewoptions)) {
1130                $this->reviewoptions = quiz_get_review_options($this->get_quiz(),
1131                        $this->attempt, $this->quizobj->get_context());
1132                if ($this->is_own_preview()) {
1133                    // It should  always be possible for a teacher to review their
1134                    // own preview irrespective of the review options settings.
1135                    $this->reviewoptions->attempt = true;
1136                }
1137            }
1138            return $this->reviewoptions;
1139
1140        } else {
1141            $options = mod_quiz_display_options::make_from_quiz($this->get_quiz(),
1142                    mod_quiz_display_options::DURING);
1143            $options->flags = quiz_get_flag_option($this->attempt, $this->quizobj->get_context());
1144            return $options;
1145        }
1146    }
1147
1148    /**
1149     * Wrapper that the correct mod_quiz_display_options for this quiz at the
1150     * moment.
1151     *
1152     * @param bool $reviewing true for review page, else attempt page.
1153     * @param int $slot which question is being displayed.
1154     * @param moodle_url $thispageurl to return to after the editing form is
1155     *      submitted or cancelled. If null, no edit link will be generated.
1156     *
1157     * @return question_display_options the render options for this user on this
1158     *      attempt, with extra info to generate an edit link, if applicable.
1159     */
1160    public function get_display_options_with_edit_link($reviewing, $slot, $thispageurl) {
1161        $options = clone($this->get_display_options($reviewing));
1162
1163        if (!$thispageurl) {
1164            return $options;
1165        }
1166
1167        if (!($reviewing || $this->is_preview())) {
1168            return $options;
1169        }
1170
1171        $question = $this->quba->get_question($slot, false);
1172        if (!question_has_capability_on($question, 'edit', $question->category)) {
1173            return $options;
1174        }
1175
1176        $options->editquestionparams['cmid'] = $this->get_cmid();
1177        $options->editquestionparams['returnurl'] = $thispageurl;
1178
1179        return $options;
1180    }
1181
1182    /**
1183     * @param int $page page number
1184     * @return bool true if this is the last page of the quiz.
1185     */
1186    public function is_last_page($page) {
1187        return $page == count($this->pagelayout) - 1;
1188    }
1189
1190    /**
1191     * Return the list of slot numbers for either a given page of the quiz, or for the
1192     * whole quiz.
1193     *
1194     * @param mixed $page string 'all' or integer page number.
1195     * @return array the requested list of slot numbers.
1196     */
1197    public function get_slots($page = 'all') {
1198        if ($page === 'all') {
1199            $numbers = array();
1200            foreach ($this->pagelayout as $numbersonpage) {
1201                $numbers = array_merge($numbers, $numbersonpage);
1202            }
1203            return $numbers;
1204        } else {
1205            return $this->pagelayout[$page];
1206        }
1207    }
1208
1209    /**
1210     * Return the list of slot numbers for either a given page of the quiz, or for the
1211     * whole quiz.
1212     *
1213     * @param mixed $page string 'all' or integer page number.
1214     * @return array the requested list of slot numbers.
1215     */
1216    public function get_active_slots($page = 'all') {
1217        $activeslots = array();
1218        foreach ($this->get_slots($page) as $slot) {
1219            if (!$this->is_blocked_by_previous_question($slot)) {
1220                $activeslots[] = $slot;
1221            }
1222        }
1223        return $activeslots;
1224    }
1225
1226    /**
1227     * Helper method for unit tests. Get the underlying question usage object.
1228     *
1229     * @return question_usage_by_activity the usage.
1230     */
1231    public function get_question_usage() {
1232        if (!(PHPUNIT_TEST || defined('BEHAT_TEST'))) {
1233            throw new coding_exception('get_question_usage is only for use in unit tests. ' .
1234                    'For other operations, use the quiz_attempt api, or extend it properly.');
1235        }
1236        return $this->quba;
1237    }
1238
1239    /**
1240     * Get the question_attempt object for a particular question in this attempt.
1241     *
1242     * @param int $slot the number used to identify this question within this attempt.
1243     * @return question_attempt the requested question_attempt.
1244     */
1245    public function get_question_attempt($slot) {
1246        return $this->quba->get_question_attempt($slot);
1247    }
1248
1249    /**
1250     * Get all the question_attempt objects that have ever appeared in a given slot.
1251     *
1252     * This relates to the 'Try another question like this one' feature.
1253     *
1254     * @param int $slot the number used to identify this question within this attempt.
1255     * @return question_attempt[] the attempts.
1256     */
1257    public function all_question_attempts_originally_in_slot($slot) {
1258        $qas = array();
1259        foreach ($this->quba->get_attempt_iterator() as $qa) {
1260            if ($qa->get_metadata('originalslot') == $slot) {
1261                $qas[] = $qa;
1262            }
1263        }
1264        $qas[] = $this->quba->get_question_attempt($slot);
1265        return $qas;
1266    }
1267
1268    /**
1269     * Is a particular question in this attempt a real question, or something like a description.
1270     *
1271     * @param int $slot the number used to identify this question within this attempt.
1272     * @return int whether that question is a real question. Actually returns the
1273     *     question length, which could theoretically be greater than one.
1274     */
1275    public function is_real_question($slot) {
1276        return $this->quba->get_question($slot, false)->length;
1277    }
1278
1279    /**
1280     * Is a particular question in this attempt a real question, or something like a description.
1281     *
1282     * @param int $slot the number used to identify this question within this attempt.
1283     * @return bool whether that question is a real question.
1284     */
1285    public function is_question_flagged($slot) {
1286        return $this->quba->get_question_attempt($slot)->is_flagged();
1287    }
1288
1289    /**
1290     * Checks whether the question in this slot requires the previous
1291     * question to have been completed.
1292     *
1293     * @param int $slot the number used to identify this question within this attempt.
1294     * @return bool whether the previous question must have been completed before
1295     *      this one can be seen.
1296     */
1297    public function is_blocked_by_previous_question($slot) {
1298        return $slot > 1 && isset($this->slots[$slot]) && $this->slots[$slot]->requireprevious &&
1299                !$this->slots[$slot]->section->shufflequestions &&
1300                !$this->slots[$slot - 1]->section->shufflequestions &&
1301                $this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ &&
1302                !$this->get_question_state($slot - 1)->is_finished() &&
1303                $this->quba->can_question_finish_during_attempt($slot - 1);
1304    }
1305
1306    /**
1307     * Is it possible for this question to be re-started within this attempt?
1308     *
1309     * @param int $slot the number used to identify this question within this attempt.
1310     * @return bool whether the student should be given the option to restart this question now.
1311     */
1312    public function can_question_be_redone_now($slot) {
1313        return $this->get_quiz()->canredoquestions && !$this->is_finished() &&
1314                $this->get_question_state($slot)->is_finished();
1315    }
1316
1317    /**
1318     * Given a slot in this attempt, which may or not be a redone question, return the original slot.
1319     *
1320     * @param int $slot identifies a particular question in this attempt.
1321     * @return int the slot where this question was originally.
1322     */
1323    public function get_original_slot($slot) {
1324        $originalslot = $this->quba->get_question_attempt_metadata($slot, 'originalslot');
1325        if ($originalslot) {
1326            return $originalslot;
1327        } else {
1328            return $slot;
1329        }
1330    }
1331
1332    /**
1333     * Get the displayed question number for a slot.
1334     *
1335     * @param int $slot the number used to identify this question within this attempt.
1336     * @return string the displayed question number for the question in this slot.
1337     *      For example '1', '2', '3' or 'i'.
1338     */
1339    public function get_question_number($slot) {
1340        return $this->questionnumbers[$slot];
1341    }
1342
1343    /**
1344     * If the section heading, if any, that should come just before this slot.
1345     *
1346     * @param int $slot identifies a particular question in this attempt.
1347     * @return string the required heading, or null if there is not one here.
1348     */
1349    public function get_heading_before_slot($slot) {
1350        if ($this->slots[$slot]->firstinsection) {
1351            return $this->slots[$slot]->section->heading;
1352        } else {
1353            return null;
1354        }
1355    }
1356
1357    /**
1358     * Return the page of the quiz where this question appears.
1359     *
1360     * @param int $slot the number used to identify this question within this attempt.
1361     * @return int the page of the quiz this question appears on.
1362     */
1363    public function get_question_page($slot) {
1364        return $this->questionpages[$slot];
1365    }
1366
1367    /**
1368     * Return the grade obtained on a particular question, if the user is permitted
1369     * to see it. You must previously have called load_question_states to load the
1370     * state data about this question.
1371     *
1372     * @param int $slot the number used to identify this question within this attempt.
1373     * @return string the formatted grade, to the number of decimal places specified
1374     *      by the quiz.
1375     */
1376    public function get_question_name($slot) {
1377        return $this->quba->get_question($slot, false)->name;
1378    }
1379
1380    /**
1381     * Return the {@link question_state} that this question is in.
1382     *
1383     * @param int $slot the number used to identify this question within this attempt.
1384     * @return question_state the state this question is in.
1385     */
1386    public function get_question_state($slot) {
1387        return $this->quba->get_question_state($slot);
1388    }
1389
1390    /**
1391     * Return the grade obtained on a particular question, if the user is permitted
1392     * to see it. You must previously have called load_question_states to load the
1393     * state data about this question.
1394     *
1395     * @param int $slot the number used to identify this question within this attempt.
1396     * @param bool $showcorrectness Whether right/partial/wrong states should
1397     *      be distinguished.
1398     * @return string the formatted grade, to the number of decimal places specified
1399     *      by the quiz.
1400     */
1401    public function get_question_status($slot, $showcorrectness) {
1402        return $this->quba->get_question_state_string($slot, $showcorrectness);
1403    }
1404
1405    /**
1406     * Return the grade obtained on a particular question, if the user is permitted
1407     * to see it. You must previously have called load_question_states to load the
1408     * state data about this question.
1409     *
1410     * @param int $slot the number used to identify this question within this attempt.
1411     * @param bool $showcorrectness Whether right/partial/wrong states should
1412     *      be distinguished.
1413     * @return string class name for this state.
1414     */
1415    public function get_question_state_class($slot, $showcorrectness) {
1416        return $this->quba->get_question_state_class($slot, $showcorrectness);
1417    }
1418
1419    /**
1420     * Return the grade obtained on a particular question.
1421     *
1422     * You must previously have called load_question_states to load the state
1423     * data about this question.
1424     *
1425     * @param int $slot the number used to identify this question within this attempt.
1426     * @return string the formatted grade, to the number of decimal places specified by the quiz.
1427     */
1428    public function get_question_mark($slot) {
1429        return quiz_format_question_grade($this->get_quiz(), $this->quba->get_question_mark($slot));
1430    }
1431
1432    /**
1433     * Get the time of the most recent action performed on a question.
1434     *
1435     * @param int $slot the number used to identify this question within this usage.
1436     * @return int timestamp.
1437     */
1438    public function get_question_action_time($slot) {
1439        return $this->quba->get_question_action_time($slot);
1440    }
1441
1442    /**
1443     * Return the question type name for a given slot within the current attempt.
1444     *
1445     * @param int $slot the number used to identify this question within this attempt.
1446     * @return string the question type name.
1447     * @since  Moodle 3.1
1448     */
1449    public function get_question_type_name($slot) {
1450        return $this->quba->get_question($slot, false)->get_type_name();
1451    }
1452
1453    /**
1454     * Get the time remaining for an in-progress attempt, if the time is short
1455     * enough that it would be worth showing a timer.
1456     *
1457     * @param int $timenow the time to consider as 'now'.
1458     * @return int|false the number of seconds remaining for this attempt.
1459     *      False if there is no limit.
1460     */
1461    public function get_time_left_display($timenow) {
1462        if ($this->attempt->state != self::IN_PROGRESS) {
1463            return false;
1464        }
1465        return $this->get_access_manager($timenow)->get_time_left_display($this->attempt, $timenow);
1466    }
1467
1468
1469    /**
1470     * @return int the time when this attempt was submitted. 0 if it has not been
1471     * submitted yet.
1472     */
1473    public function get_submitted_date() {
1474        return $this->attempt->timefinish;
1475    }
1476
1477    /**
1478     * If the attempt is in an applicable state, work out the time by which the
1479     * student should next do something.
1480     *
1481     * @return int timestamp by which the student needs to do something.
1482     */
1483    public function get_due_date() {
1484        $deadlines = array();
1485        if ($this->quizobj->get_quiz()->timelimit) {
1486            $deadlines[] = $this->attempt->timestart + $this->quizobj->get_quiz()->timelimit;
1487        }
1488        if ($this->quizobj->get_quiz()->timeclose) {
1489            $deadlines[] = $this->quizobj->get_quiz()->timeclose;
1490        }
1491        if ($deadlines) {
1492            $duedate = min($deadlines);
1493        } else {
1494            return false;
1495        }
1496
1497        switch ($this->attempt->state) {
1498            case self::IN_PROGRESS:
1499                return $duedate;
1500
1501            case self::OVERDUE:
1502                return $duedate + $this->quizobj->get_quiz()->graceperiod;
1503
1504            default:
1505                throw new coding_exception('Unexpected state: ' . $this->attempt->state);
1506        }
1507    }
1508
1509    // URLs related to this attempt ============================================
1510    /**
1511     * @return string quiz view url.
1512     */
1513    public function view_url() {
1514        return $this->quizobj->view_url();
1515    }
1516
1517    /**
1518     * Get the URL to start or continue an attempt.
1519     *
1520     * @param int|null $slot which question in the attempt to go to after starting (optional).
1521     * @param int $page which page in the attempt to go to after starting.
1522     * @return string the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter.
1523     */
1524    public function start_attempt_url($slot = null, $page = -1) {
1525        if ($page == -1 && !is_null($slot)) {
1526            $page = $this->get_question_page($slot);
1527        } else {
1528            $page = 0;
1529        }
1530        return $this->quizobj->start_attempt_url($page);
1531    }
1532
1533    /**
1534     * Generates the title of the attempt page.
1535     *
1536     * @param int $page the page number (starting with 0) in the attempt.
1537     * @return string attempt page title.
1538     */
1539    public function attempt_page_title(int $page) : string {
1540        if ($this->get_num_pages() > 1) {
1541            $a = new stdClass();
1542            $a->name = $this->get_quiz_name();
1543            $a->currentpage = $page + 1;
1544            $a->totalpages = $this->get_num_pages();
1545            $title = get_string('attempttitlepaged', 'quiz', $a);
1546        } else {
1547            $title = get_string('attempttitle', 'quiz', $this->get_quiz_name());
1548        }
1549
1550        return $title;
1551    }
1552
1553    /**
1554     * @param int|null $slot if specified, the slot number of a specific question to link to.
1555     * @param int $page if specified, a particular page to link to. If not given deduced
1556     *      from $slot, or goes to the first page.
1557     * @param int $thispage if not -1, the current page. Will cause links to other things on
1558     * this page to be output as only a fragment.
1559     * @return string the URL to continue this attempt.
1560     */
1561    public function attempt_url($slot = null, $page = -1, $thispage = -1) {
1562        return $this->page_and_question_url('attempt', $slot, $page, false, $thispage);
1563    }
1564
1565    /**
1566     * Generates the title of the summary page.
1567     *
1568     * @return string summary page title.
1569     */
1570    public function summary_page_title() : string {
1571        return get_string('attemptsummarytitle', 'quiz', $this->get_quiz_name());
1572    }
1573
1574    /**
1575     * @return moodle_url the URL of this quiz's summary page.
1576     */
1577    public function summary_url() {
1578        return new moodle_url('/mod/quiz/summary.php', array('attempt' => $this->attempt->id, 'cmid' => $this->get_cmid()));
1579    }
1580
1581    /**
1582     * @return moodle_url the URL of this quiz's summary page.
1583     */
1584    public function processattempt_url() {
1585        return new moodle_url('/mod/quiz/processattempt.php');
1586    }
1587
1588    /**
1589     * Generates the title of the review page.
1590     *
1591     * @param int $page the page number (starting with 0) in the attempt.
1592     * @param bool $showall whether the review page contains the entire attempt on one page.
1593     * @return string title of the review page.
1594     */
1595    public function review_page_title(int $page, bool $showall = false) : string {
1596        if (!$showall && $this->get_num_pages() > 1) {
1597            $a = new stdClass();
1598            $a->name = $this->get_quiz_name();
1599            $a->currentpage = $page + 1;
1600            $a->totalpages = $this->get_num_pages();
1601            $title = get_string('attemptreviewtitlepaged', 'quiz', $a);
1602        } else {
1603            $title = get_string('attemptreviewtitle', 'quiz', $this->get_quiz_name());
1604        }
1605
1606        return $title;
1607    }
1608
1609    /**
1610     * @param int|null $slot indicates which question to link to.
1611     * @param int $page if specified, the URL of this particular page of the attempt, otherwise
1612     *      the URL will go to the first page.  If -1, deduce $page from $slot.
1613     * @param bool|null $showall if true, the URL will be to review the entire attempt on one page,
1614     *      and $page will be ignored. If null, a sensible default will be chosen.
1615     * @param int $thispage if not -1, the current page. Will cause links to other things on
1616     *      this page to be output as only a fragment.
1617     * @return string the URL to review this attempt.
1618     */
1619    public function review_url($slot = null, $page = -1, $showall = null, $thispage = -1) {
1620        return $this->page_and_question_url('review', $slot, $page, $showall, $thispage);
1621    }
1622
1623    /**
1624     * By default, should this script show all questions on one page for this attempt?
1625     *
1626     * @param string $script the script name, e.g. 'attempt', 'summary', 'review'.
1627     * @return bool whether show all on one page should be on by default.
1628     */
1629    public function get_default_show_all($script) {
1630        return $script == 'review' && count($this->questionpages) < self::MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL;
1631    }
1632
1633    // Bits of content =========================================================
1634
1635    /**
1636     * If $reviewoptions->attempt is false, meaning that students can't review this
1637     * attempt at the moment, return an appropriate string explaining why.
1638     *
1639     * @param bool $short if true, return a shorter string.
1640     * @return string an appropriate message.
1641     */
1642    public function cannot_review_message($short = false) {
1643        return $this->quizobj->cannot_review_message(
1644                $this->get_attempt_state(), $short);
1645    }
1646
1647    /**
1648     * Initialise the JS etc. required all the questions on a page.
1649     *
1650     * @param int|string $page a page number, or 'all'.
1651     * @param bool $showall if true forces page number to all.
1652     * @return string HTML to output - mostly obsolete, will probably be an empty string.
1653     */
1654    public function get_html_head_contributions($page = 'all', $showall = false) {
1655        if ($showall) {
1656            $page = 'all';
1657        }
1658        $result = '';
1659        foreach ($this->get_slots($page) as $slot) {
1660            $result .= $this->quba->render_question_head_html($slot);
1661        }
1662        $result .= question_engine::initialise_js();
1663        return $result;
1664    }
1665
1666    /**
1667     * Initialise the JS etc. required by one question.
1668     *
1669     * @param int $slot the question slot number.
1670     * @return string HTML to output - but this is mostly obsolete. Will probably be an empty string.
1671     */
1672    public function get_question_html_head_contributions($slot) {
1673        return $this->quba->render_question_head_html($slot) .
1674                question_engine::initialise_js();
1675    }
1676
1677    /**
1678     * Print the HTML for the start new preview button, if the current user
1679     * is allowed to see one.
1680     *
1681     * @return string HTML for the button.
1682     */
1683    public function restart_preview_button() {
1684        global $OUTPUT;
1685        if ($this->is_preview() && $this->is_preview_user()) {
1686            return $OUTPUT->single_button(new moodle_url(
1687                    $this->start_attempt_url(), array('forcenew' => true)),
1688                    get_string('startnewpreview', 'quiz'));
1689        } else {
1690            return '';
1691        }
1692    }
1693
1694    /**
1695     * Generate the HTML that displayes the question in its current state, with
1696     * the appropriate display options.
1697     *
1698     * @param int $slot identifies the question in the attempt.
1699     * @param bool $reviewing is the being printed on an attempt or a review page.
1700     * @param mod_quiz_renderer $renderer the quiz renderer.
1701     * @param moodle_url $thispageurl the URL of the page this question is being printed on.
1702     * @return string HTML for the question in its current state.
1703     */
1704    public function render_question($slot, $reviewing, mod_quiz_renderer $renderer, $thispageurl = null) {
1705        if ($this->is_blocked_by_previous_question($slot)) {
1706            $placeholderqa = $this->make_blocked_question_placeholder($slot);
1707
1708            $displayoptions = $this->get_display_options($reviewing);
1709            $displayoptions->manualcomment = question_display_options::HIDDEN;
1710            $displayoptions->history = question_display_options::HIDDEN;
1711            $displayoptions->readonly = true;
1712
1713            return html_writer::div($placeholderqa->render($displayoptions,
1714                    $this->get_question_number($this->get_original_slot($slot))),
1715                    'mod_quiz-blocked_question_warning');
1716        }
1717
1718        return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, null);
1719    }
1720
1721    /**
1722     * Helper used by {@link render_question()} and {@link render_question_at_step()}.
1723     *
1724     * @param int $slot identifies the question in the attempt.
1725     * @param bool $reviewing is the being printed on an attempt or a review page.
1726     * @param moodle_url $thispageurl the URL of the page this question is being printed on.
1727     * @param mod_quiz_renderer $renderer the quiz renderer.
1728     * @param int|null $seq the seq number of the past state to display.
1729     * @return string HTML fragment.
1730     */
1731    protected function render_question_helper($slot, $reviewing, $thispageurl,
1732            mod_quiz_renderer $renderer, $seq) {
1733        $originalslot = $this->get_original_slot($slot);
1734        $number = $this->get_question_number($originalslot);
1735        $displayoptions = $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl);
1736
1737        if ($slot != $originalslot) {
1738            $originalmaxmark = $this->get_question_attempt($slot)->get_max_mark();
1739            $this->get_question_attempt($slot)->set_max_mark($this->get_question_attempt($originalslot)->get_max_mark());
1740        }
1741
1742        if ($this->can_question_be_redone_now($slot)) {
1743            $displayoptions->extrainfocontent = $renderer->redo_question_button(
1744                    $slot, $displayoptions->readonly);
1745        }
1746
1747        if ($displayoptions->history && $displayoptions->questionreviewlink) {
1748            $links = $this->links_to_other_redos($slot, $displayoptions->questionreviewlink);
1749            if ($links) {
1750                $displayoptions->extrahistorycontent = html_writer::tag('p',
1751                        get_string('redoesofthisquestion', 'quiz', $renderer->render($links)));
1752            }
1753        }
1754
1755        if ($seq === null) {
1756            $output = $this->quba->render_question($slot, $displayoptions, $number);
1757        } else {
1758            $output = $this->quba->render_question_at_step($slot, $seq, $displayoptions, $number);
1759        }
1760
1761        if ($slot != $originalslot) {
1762            $this->get_question_attempt($slot)->set_max_mark($originalmaxmark);
1763        }
1764
1765        return $output;
1766    }
1767
1768    /**
1769     * Create a fake question to be displayed in place of a question that is blocked
1770     * until the previous question has been answered.
1771     *
1772     * @param int $slot int slot number of the question to replace.
1773     * @return question_attempt the placeholder question attempt.
1774     */
1775    protected function make_blocked_question_placeholder($slot) {
1776        $replacedquestion = $this->get_question_attempt($slot)->get_question(false);
1777
1778        question_bank::load_question_definition_classes('description');
1779        $question = new qtype_description_question();
1780        $question->id = $replacedquestion->id;
1781        $question->category = null;
1782        $question->parent = 0;
1783        $question->qtype = question_bank::get_qtype('description');
1784        $question->name = '';
1785        $question->questiontext = get_string('questiondependsonprevious', 'quiz');
1786        $question->questiontextformat = FORMAT_HTML;
1787        $question->generalfeedback = '';
1788        $question->defaultmark = $this->quba->get_question_max_mark($slot);
1789        $question->length = $replacedquestion->length;
1790        $question->penalty = 0;
1791        $question->stamp = '';
1792        $question->version = 0;
1793        $question->hidden = 0;
1794        $question->timecreated = null;
1795        $question->timemodified = null;
1796        $question->createdby = null;
1797        $question->modifiedby = null;
1798
1799        $placeholderqa = new question_attempt($question, $this->quba->get_id(),
1800                null, $this->quba->get_question_max_mark($slot));
1801        $placeholderqa->set_slot($slot);
1802        $placeholderqa->start($this->get_quiz()->preferredbehaviour, 1);
1803        $placeholderqa->set_flagged($this->is_question_flagged($slot));
1804        return $placeholderqa;
1805    }
1806
1807    /**
1808     * Like {@link render_question()} but displays the question at the past step
1809     * indicated by $seq, rather than showing the latest step.
1810     *
1811     * @param int $slot the slot number of a question in this quiz attempt.
1812     * @param int $seq the seq number of the past state to display.
1813     * @param bool $reviewing is the being printed on an attempt or a review page.
1814     * @param mod_quiz_renderer $renderer the quiz renderer.
1815     * @param moodle_url $thispageurl the URL of the page this question is being printed on.
1816     * @return string HTML for the question in its current state.
1817     */
1818    public function render_question_at_step($slot, $seq, $reviewing,
1819            mod_quiz_renderer $renderer, $thispageurl = null) {
1820        return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, $seq);
1821    }
1822
1823    /**
1824     * Wrapper round print_question from lib/questionlib.php.
1825     *
1826     * @param int $slot the id of a question in this quiz attempt.
1827     * @return string HTML of the question.
1828     */
1829    public function render_question_for_commenting($slot) {
1830        $options = $this->get_display_options(true);
1831        $options->hide_all_feedback();
1832        $options->manualcomment = question_display_options::EDITABLE;
1833        return $this->quba->render_question($slot, $options,
1834                $this->get_question_number($slot));
1835    }
1836
1837    /**
1838     * Check wheter access should be allowed to a particular file.
1839     *
1840     * @param int $slot the slot of a question in this quiz attempt.
1841     * @param bool $reviewing is the being printed on an attempt or a review page.
1842     * @param int $contextid the file context id from the request.
1843     * @param string $component the file component from the request.
1844     * @param string $filearea the file area from the request.
1845     * @param array $args extra part components from the request.
1846     * @param bool $forcedownload whether to force download.
1847     * @return string HTML for the question in its current state.
1848     */
1849    public function check_file_access($slot, $reviewing, $contextid, $component,
1850            $filearea, $args, $forcedownload) {
1851        $options = $this->get_display_options($reviewing);
1852
1853        // Check permissions - warning there is similar code in review.php and
1854        // reviewquestion.php. If you change on, change them all.
1855        if ($reviewing && $this->is_own_attempt() && !$options->attempt) {
1856            return false;
1857        }
1858
1859        if ($reviewing && !$this->is_own_attempt() && !$this->is_review_allowed()) {
1860            return false;
1861        }
1862
1863        return $this->quba->check_file_access($slot, $options,
1864                $component, $filearea, $args, $forcedownload);
1865    }
1866
1867    /**
1868     * Get the navigation panel object for this attempt.
1869     *
1870     * @param mod_quiz_renderer $output the quiz renderer to use to output things.
1871     * @param string $panelclass The type of panel, quiz_attempt_nav_panel or quiz_review_nav_panel
1872     * @param int $page the current page number.
1873     * @param bool $showall whether we are showing the whole quiz on one page. (Used by review.php.)
1874     * @return block_contents the requested object.
1875     */
1876    public function get_navigation_panel(mod_quiz_renderer $output,
1877             $panelclass, $page, $showall = false) {
1878        $panel = new $panelclass($this, $this->get_display_options(true), $page, $showall);
1879
1880        $bc = new block_contents();
1881        $bc->attributes['id'] = 'mod_quiz_navblock';
1882        $bc->attributes['role'] = 'navigation';
1883        $bc->attributes['aria-labelledby'] = 'mod_quiz_navblock_title';
1884        $bc->title = html_writer::span(get_string('quiznavigation', 'quiz'), '', array('id' => 'mod_quiz_navblock_title'));
1885        $bc->content = $output->navigation_panel($panel);
1886        return $bc;
1887    }
1888
1889    /**
1890     * Return an array of variant URLs to other attempts at this quiz.
1891     *
1892     * The $url passed in must contain an attempt parameter.
1893     *
1894     * The {@link mod_quiz_links_to_other_attempts} object returned contains an
1895     * array with keys that are the attempt number, 1, 2, 3.
1896     * The array values are either a {@link moodle_url} with the attempt parameter
1897     * updated to point to the attempt id of the other attempt, or null corresponding
1898     * to the current attempt number.
1899     *
1900     * @param moodle_url $url a URL.
1901     * @return mod_quiz_links_to_other_attempts|bool containing array int => null|moodle_url.
1902     *      False if none.
1903     */
1904    public function links_to_other_attempts(moodle_url $url) {
1905        $attempts = quiz_get_user_attempts($this->get_quiz()->id, $this->attempt->userid, 'all');
1906        if (count($attempts) <= 1) {
1907            return false;
1908        }
1909
1910        $links = new mod_quiz_links_to_other_attempts();
1911        foreach ($attempts as $at) {
1912            if ($at->id == $this->attempt->id) {
1913                $links->links[$at->attempt] = null;
1914            } else {
1915                $links->links[$at->attempt] = new moodle_url($url, array('attempt' => $at->id));
1916            }
1917        }
1918        return $links;
1919    }
1920
1921    /**
1922     * Return an array of variant URLs to other redos of the question in a particular slot.
1923     *
1924     * The $url passed in must contain a slot parameter.
1925     *
1926     * The {@link mod_quiz_links_to_other_attempts} object returned contains an
1927     * array with keys that are the redo number, 1, 2, 3.
1928     * The array values are either a {@link moodle_url} with the slot parameter
1929     * updated to point to the slot that has that redo of this question; or null
1930     * corresponding to the redo identified by $slot.
1931     *
1932     * @param int $slot identifies a question in this attempt.
1933     * @param moodle_url $baseurl the base URL to modify to generate each link.
1934     * @return mod_quiz_links_to_other_attempts|null containing array int => null|moodle_url,
1935     *      or null if the question in this slot has not been redone.
1936     */
1937    public function links_to_other_redos($slot, moodle_url $baseurl) {
1938        $originalslot = $this->get_original_slot($slot);
1939
1940        $qas = $this->all_question_attempts_originally_in_slot($originalslot);
1941        if (count($qas) <= 1) {
1942            return null;
1943        }
1944
1945        $links = new mod_quiz_links_to_other_attempts();
1946        $index = 1;
1947        foreach ($qas as $qa) {
1948            if ($qa->get_slot() == $slot) {
1949                $links->links[$index] = null;
1950            } else {
1951                $url = new moodle_url($baseurl, array('slot' => $qa->get_slot()));
1952                $links->links[$index] = new action_link($url, $index,
1953                        new popup_action('click', $url, 'reviewquestion',
1954                                array('width' => 450, 'height' => 650)),
1955                        array('title' => get_string('reviewresponse', 'question')));
1956            }
1957            $index++;
1958        }
1959        return $links;
1960    }
1961
1962    // Methods for processing ==================================================
1963
1964    /**
1965     * Check this attempt, to see if there are any state transitions that should
1966     * happen automatically. This function will update the attempt checkstatetime.
1967     * @param int $timestamp the timestamp that should be stored as the modified
1968     * @param bool $studentisonline is the student currently interacting with Moodle?
1969     */
1970    public function handle_if_time_expired($timestamp, $studentisonline) {
1971
1972        $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt);
1973
1974        if ($timeclose === false || $this->is_preview()) {
1975            $this->update_timecheckstate(null);
1976            return; // No time limit.
1977        }
1978        if ($timestamp < $timeclose) {
1979            $this->update_timecheckstate($timeclose);
1980            return; // Time has not yet expired.
1981        }
1982
1983        // If the attempt is already overdue, look to see if it should be abandoned ...
1984        if ($this->attempt->state == self::OVERDUE) {
1985            $timeoverdue = $timestamp - $timeclose;
1986            $graceperiod = $this->quizobj->get_quiz()->graceperiod;
1987            if ($timeoverdue >= $graceperiod) {
1988                $this->process_abandon($timestamp, $studentisonline);
1989            } else {
1990                // Overdue time has not yet expired
1991                $this->update_timecheckstate($timeclose + $graceperiod);
1992            }
1993            return; // ... and we are done.
1994        }
1995
1996        if ($this->attempt->state != self::IN_PROGRESS) {
1997            $this->update_timecheckstate(null);
1998            return; // Attempt is already in a final state.
1999        }
2000
2001        // Otherwise, we were in quiz_attempt::IN_PROGRESS, and time has now expired.
2002        // Transition to the appropriate state.
2003        switch ($this->quizobj->get_quiz()->overduehandling) {
2004            case 'autosubmit':
2005                $this->process_finish($timestamp, false, $studentisonline ? $timestamp : $timeclose);
2006                return;
2007
2008            case 'graceperiod':
2009                $this->process_going_overdue($timestamp, $studentisonline);
2010                return;
2011
2012            case 'autoabandon':
2013                $this->process_abandon($timestamp, $studentisonline);
2014                return;
2015        }
2016
2017        // This is an overdue attempt with no overdue handling defined, so just abandon.
2018        $this->process_abandon($timestamp, $studentisonline);
2019        return;
2020    }
2021
2022    /**
2023     * Process all the actions that were submitted as part of the current request.
2024     *
2025     * @param int $timestamp the timestamp that should be stored as the modified.
2026     *      time in the database for these actions. If null, will use the current time.
2027     * @param bool $becomingoverdue
2028     * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data.
2029     *      There are two formats supported here, for historical reasons. The newer approach is to pass an array created by
2030     *      {@link core_question_generator::get_simulated_post_data_for_questions_in_usage()}.
2031     *      the second is to pass an array slot no => contains arrays representing student
2032     *      responses which will be passed to {@link question_definition::prepare_simulated_post_data()}.
2033     *      This second method will probably get deprecated one day.
2034     */
2035    public function process_submitted_actions($timestamp, $becomingoverdue = false, $simulatedresponses = null) {
2036        global $DB;
2037
2038        $transaction = $DB->start_delegated_transaction();
2039
2040        if ($simulatedresponses !== null) {
2041            if (is_int(key($simulatedresponses))) {
2042                // Legacy approach. Should be removed one day.
2043                $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses);
2044            } else {
2045                $simulatedpostdata = $simulatedresponses;
2046            }
2047        } else {
2048            $simulatedpostdata = null;
2049        }
2050
2051        $this->quba->process_all_actions($timestamp, $simulatedpostdata);
2052        question_engine::save_questions_usage_by_activity($this->quba);
2053
2054        $this->attempt->timemodified = $timestamp;
2055        if ($this->attempt->state == self::FINISHED) {
2056            $this->attempt->sumgrades = $this->quba->get_total_mark();
2057        }
2058        if ($becomingoverdue) {
2059            $this->process_going_overdue($timestamp, true);
2060        } else {
2061            $DB->update_record('quiz_attempts', $this->attempt);
2062        }
2063
2064        if (!$this->is_preview() && $this->attempt->state == self::FINISHED) {
2065            quiz_save_best_grade($this->get_quiz(), $this->get_userid());
2066        }
2067
2068        $transaction->allow_commit();
2069    }
2070
2071    /**
2072     * Replace a question in an attempt with a new attempt at the same question.
2073     *
2074     * Well, for randomised questions, it won't be the same question, it will be
2075     * a different randomised selection.
2076     *
2077     * @param int $slot the question to restart.
2078     * @param int $timestamp the timestamp to record for this action.
2079     */
2080    public function process_redo_question($slot, $timestamp) {
2081        global $DB;
2082
2083        if (!$this->can_question_be_redone_now($slot)) {
2084            throw new coding_exception('Attempt to restart the question in slot ' . $slot .
2085                    ' when it is not in a state to be restarted.');
2086        }
2087
2088        $qubaids = new \mod_quiz\question\qubaids_for_users_attempts(
2089                $this->get_quizid(), $this->get_userid(), 'all', true);
2090
2091        $transaction = $DB->start_delegated_transaction();
2092
2093        // Choose the replacement question.
2094        $questiondata = $DB->get_record('question',
2095                array('id' => $this->slots[$slot]->questionid));
2096        if ($questiondata->qtype != 'random') {
2097            $newqusetionid = $questiondata->id;
2098        } else {
2099            $tagids = quiz_retrieve_slot_tag_ids($this->slots[$slot]->id);
2100
2101            $randomloader = new \core_question\bank\random_question_loader($qubaids, array());
2102            $newqusetionid = $randomloader->get_next_question_id($questiondata->category,
2103                    (bool) $questiondata->questiontext, $tagids);
2104            if ($newqusetionid === null) {
2105                throw new moodle_exception('notenoughrandomquestions', 'quiz',
2106                        $this->quizobj->view_url(), $questiondata);
2107            }
2108        }
2109
2110        // Add the question to the usage. It is important we do this before we choose a variant.
2111        $newquestion = question_bank::load_question($newqusetionid);
2112        $newslot = $this->quba->add_question_in_place_of_other($slot, $newquestion);
2113
2114        // Choose the variant.
2115        if ($newquestion->get_num_variants() == 1) {
2116            $variant = 1;
2117        } else {
2118            $variantstrategy = new core_question\engine\variants\least_used_strategy(
2119                    $this->quba, $qubaids);
2120            $variant = $variantstrategy->choose_variant($newquestion->get_num_variants(),
2121                    $newquestion->get_variants_selection_seed());
2122        }
2123
2124        // Start the question.
2125        $this->quba->start_question($slot, $variant);
2126        $this->quba->set_max_mark($newslot, 0);
2127        $this->quba->set_question_attempt_metadata($newslot, 'originalslot', $slot);
2128        question_engine::save_questions_usage_by_activity($this->quba);
2129
2130        $transaction->allow_commit();
2131    }
2132
2133    /**
2134     * Process all the autosaved data that was part of the current request.
2135     *
2136     * @param int $timestamp the timestamp that should be stored as the modified.
2137     * time in the database for these actions. If null, will use the current time.
2138     */
2139    public function process_auto_save($timestamp) {
2140        global $DB;
2141
2142        $transaction = $DB->start_delegated_transaction();
2143
2144        $this->quba->process_all_autosaves($timestamp);
2145        question_engine::save_questions_usage_by_activity($this->quba);
2146
2147        $transaction->allow_commit();
2148    }
2149
2150    /**
2151     * Update the flagged state for all question_attempts in this usage, if their
2152     * flagged state was changed in the request.
2153     */
2154    public function save_question_flags() {
2155        global $DB;
2156
2157        $transaction = $DB->start_delegated_transaction();
2158        $this->quba->update_question_flags();
2159        question_engine::save_questions_usage_by_activity($this->quba);
2160        $transaction->allow_commit();
2161    }
2162
2163    /**
2164     * Submit the attempt.
2165     *
2166     * The separate $timefinish argument should be used when the quiz attempt
2167     * is being processed asynchronously (for example when cron is submitting
2168     * attempts where the time has expired).
2169     *
2170     * @param int $timestamp the time to record as last modified time.
2171     * @param bool $processsubmitted if true, and question responses in the current
2172     *      POST request are stored to be graded, before the attempt is finished.
2173     * @param ?int $timefinish if set, use this as the finish time for the attempt.
2174     *      (otherwise use $timestamp as the finish time as well).
2175     */
2176    public function process_finish($timestamp, $processsubmitted, $timefinish = null) {
2177        global $DB;
2178
2179        $transaction = $DB->start_delegated_transaction();
2180
2181        if ($processsubmitted) {
2182            $this->quba->process_all_actions($timestamp);
2183        }
2184        $this->quba->finish_all_questions($timestamp);
2185
2186        question_engine::save_questions_usage_by_activity($this->quba);
2187
2188        $this->attempt->timemodified = $timestamp;
2189        $this->attempt->timefinish = $timefinish ?? $timestamp;
2190        $this->attempt->sumgrades = $this->quba->get_total_mark();
2191        $this->attempt->state = self::FINISHED;
2192        $this->attempt->timecheckstate = null;
2193        $DB->update_record('quiz_attempts', $this->attempt);
2194
2195        if (!$this->is_preview()) {
2196            quiz_save_best_grade($this->get_quiz(), $this->attempt->userid);
2197
2198            // Trigger event.
2199            $this->fire_state_transition_event('\mod_quiz\event\attempt_submitted', $timestamp);
2200
2201            // Tell any access rules that care that the attempt is over.
2202            $this->get_access_manager($timestamp)->current_attempt_finished();
2203        }
2204
2205        $transaction->allow_commit();
2206    }
2207
2208    /**
2209     * Update this attempt timecheckstate if necessary.
2210     *
2211     * @param int|null $time the timestamp to set.
2212     */
2213    public function update_timecheckstate($time) {
2214        global $DB;
2215        if ($this->attempt->timecheckstate !== $time) {
2216            $this->attempt->timecheckstate = $time;
2217            $DB->set_field('quiz_attempts', 'timecheckstate', $time, array('id' => $this->attempt->id));
2218        }
2219    }
2220
2221    /**
2222     * Mark this attempt as now overdue.
2223     *
2224     * @param int $timestamp the time to deem as now.
2225     * @param bool $studentisonline is the student currently interacting with Moodle?
2226     */
2227    public function process_going_overdue($timestamp, $studentisonline) {
2228        global $DB;
2229
2230        $transaction = $DB->start_delegated_transaction();
2231        $this->attempt->timemodified = $timestamp;
2232        $this->attempt->state = self::OVERDUE;
2233        // If we knew the attempt close time, we could compute when the graceperiod ends.
2234        // Instead we'll just fix it up through cron.
2235        $this->attempt->timecheckstate = $timestamp;
2236        $DB->update_record('quiz_attempts', $this->attempt);
2237
2238        $this->fire_state_transition_event('\mod_quiz\event\attempt_becameoverdue', $timestamp);
2239
2240        $transaction->allow_commit();
2241
2242        quiz_send_overdue_message($this);
2243    }
2244
2245    /**
2246     * Mark this attempt as abandoned.
2247     *
2248     * @param int $timestamp the time to deem as now.
2249     * @param bool $studentisonline is the student currently interacting with Moodle?
2250     */
2251    public function process_abandon($timestamp, $studentisonline) {
2252        global $DB;
2253
2254        $transaction = $DB->start_delegated_transaction();
2255        $this->attempt->timemodified = $timestamp;
2256        $this->attempt->state = self::ABANDONED;
2257        $this->attempt->timecheckstate = null;
2258        $DB->update_record('quiz_attempts', $this->attempt);
2259
2260        $this->fire_state_transition_event('\mod_quiz\event\attempt_abandoned', $timestamp);
2261
2262        $transaction->allow_commit();
2263    }
2264
2265    /**
2266     * Fire a state transition event.
2267     *
2268     * @param string $eventclass the event class name.
2269     * @param int $timestamp the timestamp to include in the event.
2270     */
2271    protected function fire_state_transition_event($eventclass, $timestamp) {
2272        global $USER;
2273        $quizrecord = $this->get_quiz();
2274        $params = array(
2275            'context' => $this->get_quizobj()->get_context(),
2276            'courseid' => $this->get_courseid(),
2277            'objectid' => $this->attempt->id,
2278            'relateduserid' => $this->attempt->userid,
2279            'other' => array(
2280                'submitterid' => CLI_SCRIPT ? null : $USER->id,
2281                'quizid' => $quizrecord->id
2282            )
2283        );
2284
2285        $event = $eventclass::create($params);
2286        $event->add_record_snapshot('quiz', $this->get_quiz());
2287        $event->add_record_snapshot('quiz_attempts', $this->get_attempt());
2288        $event->trigger();
2289    }
2290
2291    // Private methods =========================================================
2292
2293    /**
2294     * Get a URL for a particular question on a particular page of the quiz.
2295     * Used by {@link attempt_url()} and {@link review_url()}.
2296     *
2297     * @param string $script. Used in the URL like /mod/quiz/$script.php.
2298     * @param int $slot identifies the specific question on the page to jump to.
2299     *      0 to just use the $page parameter.
2300     * @param int $page -1 to look up the page number from the slot, otherwise
2301     *      the page number to go to.
2302     * @param bool|null $showall if true, return a URL with showall=1, and not page number.
2303     *      if null, then an intelligent default will be chosen.
2304     * @param int $thispage the page we are currently on. Links to questions on this
2305     *      page will just be a fragment #q123. -1 to disable this.
2306     * @return moodle_url The requested URL.
2307     */
2308    protected function page_and_question_url($script, $slot, $page, $showall, $thispage) {
2309
2310        $defaultshowall = $this->get_default_show_all($script);
2311        if ($showall === null && ($page == 0 || $page == -1)) {
2312            $showall = $defaultshowall;
2313        }
2314
2315        // Fix up $page.
2316        if ($page == -1) {
2317            if ($slot !== null && !$showall) {
2318                $page = $this->get_question_page($slot);
2319            } else {
2320                $page = 0;
2321            }
2322        }
2323
2324        if ($showall) {
2325            $page = 0;
2326        }
2327
2328        // Add a fragment to scroll down to the question.
2329        $fragment = '';
2330        if ($slot !== null) {
2331            if ($slot == reset($this->pagelayout[$page])) {
2332                // First question on page, go to top.
2333                $fragment = '#';
2334            } else {
2335                $qa = $this->get_question_attempt($slot);
2336                $fragment = '#' . $qa->get_outer_question_div_unique_id();
2337            }
2338        }
2339
2340        // Work out the correct start to the URL.
2341        if ($thispage == $page) {
2342            return new moodle_url($fragment);
2343
2344        } else {
2345            $url = new moodle_url('/mod/quiz/' . $script . '.php' . $fragment,
2346                    array('attempt' => $this->attempt->id, 'cmid' => $this->get_cmid()));
2347            if ($page == 0 && $showall != $defaultshowall) {
2348                $url->param('showall', (int) $showall);
2349            } else if ($page > 0) {
2350                $url->param('page', $page);
2351            }
2352            return $url;
2353        }
2354    }
2355
2356    /**
2357     * Process responses during an attempt at a quiz.
2358     *
2359     * @param  int $timenow time when the processing started.
2360     * @param  bool $finishattempt whether to finish the attempt or not.
2361     * @param  bool $timeup true if form was submitted by timer.
2362     * @param  int $thispage current page number.
2363     * @return string the attempt state once the data has been processed.
2364     * @since  Moodle 3.1
2365     */
2366    public function process_attempt($timenow, $finishattempt, $timeup, $thispage) {
2367        global $DB;
2368
2369        $transaction = $DB->start_delegated_transaction();
2370
2371        // If there is only a very small amount of time left, there is no point trying
2372        // to show the student another page of the quiz. Just finish now.
2373        $graceperiodmin = null;
2374        $accessmanager = $this->get_access_manager($timenow);
2375        $timeclose = $accessmanager->get_end_time($this->get_attempt());
2376
2377        // Don't enforce timeclose for previews.
2378        if ($this->is_preview()) {
2379            $timeclose = false;
2380        }
2381        $toolate = false;
2382        if ($timeclose !== false && $timenow > $timeclose - QUIZ_MIN_TIME_TO_CONTINUE) {
2383            $timeup = true;
2384            $graceperiodmin = get_config('quiz', 'graceperiodmin');
2385            if ($timenow > $timeclose + $graceperiodmin) {
2386                $toolate = true;
2387            }
2388        }
2389
2390        // If time is running out, trigger the appropriate action.
2391        $becomingoverdue = false;
2392        $becomingabandoned = false;
2393        if ($timeup) {
2394            if ($this->get_quiz()->overduehandling == 'graceperiod') {
2395                if (is_null($graceperiodmin)) {
2396                    $graceperiodmin = get_config('quiz', 'graceperiodmin');
2397                }
2398                if ($timenow > $timeclose + $this->get_quiz()->graceperiod + $graceperiodmin) {
2399                    // Grace period has run out.
2400                    $finishattempt = true;
2401                    $becomingabandoned = true;
2402                } else {
2403                    $becomingoverdue = true;
2404                }
2405            } else {
2406                $finishattempt = true;
2407            }
2408        }
2409
2410        // Don't log - we will end with a redirect to a page that is logged.
2411
2412        if (!$finishattempt) {
2413            // Just process the responses for this page and go to the next page.
2414            if (!$toolate) {
2415                try {
2416                    $this->process_submitted_actions($timenow, $becomingoverdue);
2417
2418                } catch (question_out_of_sequence_exception $e) {
2419                    throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question',
2420                            $this->attempt_url(null, $thispage));
2421
2422                } catch (Exception $e) {
2423                    // This sucks, if we display our own custom error message, there is no way
2424                    // to display the original stack trace.
2425                    $debuginfo = '';
2426                    if (!empty($e->debuginfo)) {
2427                        $debuginfo = $e->debuginfo;
2428                    }
2429                    throw new moodle_exception('errorprocessingresponses', 'question',
2430                            $this->attempt_url(null, $thispage), $e->getMessage(), $debuginfo);
2431                }
2432
2433                if (!$becomingoverdue) {
2434                    foreach ($this->get_slots() as $slot) {
2435                        if (optional_param('redoslot' . $slot, false, PARAM_BOOL)) {
2436                            $this->process_redo_question($slot, $timenow);
2437                        }
2438                    }
2439                }
2440
2441            } else {
2442                // The student is too late.
2443                $this->process_going_overdue($timenow, true);
2444            }
2445
2446            $transaction->allow_commit();
2447
2448            return $becomingoverdue ? self::OVERDUE : self::IN_PROGRESS;
2449        }
2450
2451        // Update the quiz attempt record.
2452        try {
2453            if ($becomingabandoned) {
2454                $this->process_abandon($timenow, true);
2455            } else {
2456                $this->process_finish($timenow, !$toolate, $toolate ? $timeclose : $timenow);
2457            }
2458
2459        } catch (question_out_of_sequence_exception $e) {
2460            throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question',
2461                    $this->attempt_url(null, $thispage));
2462
2463        } catch (Exception $e) {
2464            // This sucks, if we display our own custom error message, there is no way
2465            // to display the original stack trace.
2466            $debuginfo = '';
2467            if (!empty($e->debuginfo)) {
2468                $debuginfo = $e->debuginfo;
2469            }
2470            throw new moodle_exception('errorprocessingresponses', 'question',
2471                    $this->attempt_url(null, $thispage), $e->getMessage(), $debuginfo);
2472        }
2473
2474        // Send the user to the review page.
2475        $transaction->allow_commit();
2476
2477        return $becomingabandoned ? self::ABANDONED : self::FINISHED;
2478    }
2479
2480    /**
2481     * Check a page access to see if is an out of sequence access.
2482     *
2483     * @param  int $page page number.
2484     * @return boolean false is is an out of sequence access, true otherwise.
2485     * @since Moodle 3.1
2486     */
2487    public function check_page_access($page) {
2488        if ($this->get_currentpage() != $page) {
2489            if ($this->get_navigation_method() == QUIZ_NAVMETHOD_SEQ && $this->get_currentpage() > $page) {
2490                return false;
2491            }
2492        }
2493        return true;
2494    }
2495
2496    /**
2497     * Update attempt page.
2498     *
2499     * @param  int $page page number.
2500     * @return boolean true if everything was ok, false otherwise (out of sequence access).
2501     * @since Moodle 3.1
2502     */
2503    public function set_currentpage($page) {
2504        global $DB;
2505
2506        if ($this->check_page_access($page)) {
2507            $DB->set_field('quiz_attempts', 'currentpage', $page, array('id' => $this->get_attemptid()));
2508            return true;
2509        }
2510        return false;
2511    }
2512
2513    /**
2514     * Trigger the attempt_viewed event.
2515     *
2516     * @since Moodle 3.1
2517     */
2518    public function fire_attempt_viewed_event() {
2519        $params = array(
2520            'objectid' => $this->get_attemptid(),
2521            'relateduserid' => $this->get_userid(),
2522            'courseid' => $this->get_courseid(),
2523            'context' => context_module::instance($this->get_cmid()),
2524            'other' => array(
2525                'quizid' => $this->get_quizid()
2526            )
2527        );
2528        $event = \mod_quiz\event\attempt_viewed::create($params);
2529        $event->add_record_snapshot('quiz_attempts', $this->get_attempt());
2530        $event->trigger();
2531    }
2532
2533    /**
2534     * Trigger the attempt_summary_viewed event.
2535     *
2536     * @since Moodle 3.1
2537     */
2538    public function fire_attempt_summary_viewed_event() {
2539
2540        $params = array(
2541            'objectid' => $this->get_attemptid(),
2542            'relateduserid' => $this->get_userid(),
2543            'courseid' => $this->get_courseid(),
2544            'context' => context_module::instance($this->get_cmid()),
2545            'other' => array(
2546                'quizid' => $this->get_quizid()
2547            )
2548        );
2549        $event = \mod_quiz\event\attempt_summary_viewed::create($params);
2550        $event->add_record_snapshot('quiz_attempts', $this->get_attempt());
2551        $event->trigger();
2552    }
2553
2554    /**
2555     * Trigger the attempt_reviewed event.
2556     *
2557     * @since Moodle 3.1
2558     */
2559    public function fire_attempt_reviewed_event() {
2560
2561        $params = array(
2562            'objectid' => $this->get_attemptid(),
2563            'relateduserid' => $this->get_userid(),
2564            'courseid' => $this->get_courseid(),
2565            'context' => context_module::instance($this->get_cmid()),
2566            'other' => array(
2567                'quizid' => $this->get_quizid()
2568            )
2569        );
2570        $event = \mod_quiz\event\attempt_reviewed::create($params);
2571        $event->add_record_snapshot('quiz_attempts', $this->get_attempt());
2572        $event->trigger();
2573    }
2574
2575    /**
2576     * Update the timemodifiedoffline attempt field.
2577     *
2578     * This function should be used only when web services are being used.
2579     *
2580     * @param int $time time stamp.
2581     * @return boolean false if the field is not updated because web services aren't being used.
2582     * @since Moodle 3.2
2583     */
2584    public function set_offline_modified_time($time) {
2585        // Update the timemodifiedoffline field only if web services are being used.
2586        if (WS_SERVER) {
2587            $this->attempt->timemodifiedoffline = $time;
2588            return true;
2589        }
2590        return false;
2591    }
2592
2593}
2594
2595
2596/**
2597 * Represents a heading in the navigation panel.
2598 *
2599 * @copyright  2015 The Open University
2600 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2601 * @since      Moodle 2.9
2602 */
2603class quiz_nav_section_heading implements renderable {
2604    /** @var string the heading text. */
2605    public $heading;
2606
2607    /**
2608     * Constructor.
2609     * @param string $heading the heading text
2610     */
2611    public function __construct($heading) {
2612        $this->heading = $heading;
2613    }
2614}
2615
2616
2617/**
2618 * Represents a single link in the navigation panel.
2619 *
2620 * @copyright  2011 The Open University
2621 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2622 * @since      Moodle 2.1
2623 */
2624class quiz_nav_question_button implements renderable {
2625    /** @var string id="..." to add to the HTML for this button. */
2626    public $id;
2627    /** @var string number to display in this button. Either the question number of 'i'. */
2628    public $number;
2629    /** @var string class to add to the class="" attribute to represnt the question state. */
2630    public $stateclass;
2631    /** @var string Textual description of the question state, e.g. to use as a tool tip. */
2632    public $statestring;
2633    /** @var int the page number this question is on. */
2634    public $page;
2635    /** @var bool true if this question is on the current page. */
2636    public $currentpage;
2637    /** @var bool true if this question has been flagged. */
2638    public $flagged;
2639    /** @var moodle_url the link this button goes to, or null if there should not be a link. */
2640    public $url;
2641    /** @var int QUIZ_NAVMETHOD_FREE or QUIZ_NAVMETHOD_SEQ. */
2642    public $navmethod;
2643}
2644
2645
2646/**
2647 * Represents the navigation panel, and builds a {@link block_contents} to allow
2648 * it to be output.
2649 *
2650 * @copyright  2008 Tim Hunt
2651 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2652 * @since      Moodle 2.0
2653 */
2654abstract class quiz_nav_panel_base {
2655    /** @var quiz_attempt */
2656    protected $attemptobj;
2657    /** @var question_display_options */
2658    protected $options;
2659    /** @var integer */
2660    protected $page;
2661    /** @var boolean */
2662    protected $showall;
2663
2664    public function __construct(quiz_attempt $attemptobj,
2665            question_display_options $options, $page, $showall) {
2666        $this->attemptobj = $attemptobj;
2667        $this->options = $options;
2668        $this->page = $page;
2669        $this->showall = $showall;
2670    }
2671
2672    /**
2673     * Get the buttons and section headings to go in the quiz navigation block.
2674     *
2675     * @return renderable[] the buttons, possibly interleaved with section headings.
2676     */
2677    public function get_question_buttons() {
2678        $buttons = array();
2679        foreach ($this->attemptobj->get_slots() as $slot) {
2680            if ($heading = $this->attemptobj->get_heading_before_slot($slot)) {
2681                $buttons[] = new quiz_nav_section_heading(format_string($heading));
2682            }
2683
2684            $qa = $this->attemptobj->get_question_attempt($slot);
2685            $showcorrectness = $this->options->correctness && $qa->has_marks();
2686
2687            $button = new quiz_nav_question_button();
2688            $button->id          = 'quiznavbutton' . $slot;
2689            $button->number      = $this->attemptobj->get_question_number($slot);
2690            $button->stateclass  = $qa->get_state_class($showcorrectness);
2691            $button->navmethod   = $this->attemptobj->get_navigation_method();
2692            if (!$showcorrectness && $button->stateclass == 'notanswered') {
2693                $button->stateclass = 'complete';
2694            }
2695            $button->statestring = $this->get_state_string($qa, $showcorrectness);
2696            $button->page        = $this->attemptobj->get_question_page($slot);
2697            $button->currentpage = $this->showall || $button->page == $this->page;
2698            $button->flagged     = $qa->is_flagged();
2699            $button->url         = $this->get_question_url($slot);
2700            if ($this->attemptobj->is_blocked_by_previous_question($slot)) {
2701                $button->url = null;
2702                $button->stateclass = 'blocked';
2703                $button->statestring = get_string('questiondependsonprevious', 'quiz');
2704            }
2705            $buttons[] = $button;
2706        }
2707
2708        return $buttons;
2709    }
2710
2711    protected function get_state_string(question_attempt $qa, $showcorrectness) {
2712        if ($qa->get_question(false)->length > 0) {
2713            return $qa->get_state_string($showcorrectness);
2714        }
2715
2716        // Special case handling for 'information' items.
2717        if ($qa->get_state() == question_state::$todo) {
2718            return get_string('notyetviewed', 'quiz');
2719        } else {
2720            return get_string('viewed', 'quiz');
2721        }
2722    }
2723
2724    /**
2725     * Hook for subclasses to override.
2726     *
2727     * @param mod_quiz_renderer $output the quiz renderer to use.
2728     * @return string HTML to output.
2729     */
2730    public function render_before_button_bits(mod_quiz_renderer $output) {
2731        return '';
2732    }
2733
2734    abstract public function render_end_bits(mod_quiz_renderer $output);
2735
2736    /**
2737     * Render the restart preview button.
2738     *
2739     * @param mod_quiz_renderer $output the quiz renderer to use.
2740     * @return string HTML to output.
2741     */
2742    protected function render_restart_preview_link($output) {
2743        if (!$this->attemptobj->is_own_preview()) {
2744            return '';
2745        }
2746        return $output->restart_preview_button(new moodle_url(
2747                $this->attemptobj->start_attempt_url(), array('forcenew' => true)));
2748    }
2749
2750    protected abstract function get_question_url($slot);
2751
2752    public function user_picture() {
2753        global $DB;
2754        if ($this->attemptobj->get_quiz()->showuserpicture == QUIZ_SHOWIMAGE_NONE) {
2755            return null;
2756        }
2757        $user = $DB->get_record('user', array('id' => $this->attemptobj->get_userid()));
2758        $userpicture = new user_picture($user);
2759        $userpicture->courseid = $this->attemptobj->get_courseid();
2760        if ($this->attemptobj->get_quiz()->showuserpicture == QUIZ_SHOWIMAGE_LARGE) {
2761            $userpicture->size = true;
2762        }
2763        return $userpicture;
2764    }
2765
2766    /**
2767     * Return 'allquestionsononepage' as CSS class name when $showall is set,
2768     * otherwise, return 'multipages' as CSS class name.
2769     *
2770     * @return string, CSS class name
2771     */
2772    public function get_button_container_class() {
2773        // Quiz navigation is set on 'Show all questions on one page'.
2774        if ($this->showall) {
2775            return 'allquestionsononepage';
2776        }
2777        // Quiz navigation is set on 'Show one page at a time'.
2778        return 'multipages';
2779    }
2780}
2781
2782
2783/**
2784 * Specialisation of {@link quiz_nav_panel_base} for the attempt quiz page.
2785 *
2786 * @copyright  2008 Tim Hunt
2787 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2788 * @since      Moodle 2.0
2789 */
2790class quiz_attempt_nav_panel extends quiz_nav_panel_base {
2791    public function get_question_url($slot) {
2792        if ($this->attemptobj->can_navigate_to($slot)) {
2793            return $this->attemptobj->attempt_url($slot, -1, $this->page);
2794        } else {
2795            return null;
2796        }
2797    }
2798
2799    public function render_before_button_bits(mod_quiz_renderer $output) {
2800        return html_writer::tag('div', get_string('navnojswarning', 'quiz'),
2801                array('id' => 'quiznojswarning'));
2802    }
2803
2804    public function render_end_bits(mod_quiz_renderer $output) {
2805        if ($this->page == -1) {
2806            // Don't link from the summary page to itself.
2807            return '';
2808        }
2809        return html_writer::link($this->attemptobj->summary_url(),
2810                get_string('endtest', 'quiz'), array('class' => 'endtestlink aalink')) .
2811                $output->countdown_timer($this->attemptobj, time()) .
2812                $this->render_restart_preview_link($output);
2813    }
2814}
2815
2816
2817/**
2818 * Specialisation of {@link quiz_nav_panel_base} for the review quiz page.
2819 *
2820 * @copyright  2008 Tim Hunt
2821 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2822 * @since      Moodle 2.0
2823 */
2824class quiz_review_nav_panel extends quiz_nav_panel_base {
2825    public function get_question_url($slot) {
2826        return $this->attemptobj->review_url($slot, -1, $this->showall, $this->page);
2827    }
2828
2829    public function render_end_bits(mod_quiz_renderer $output) {
2830        $html = '';
2831        if ($this->attemptobj->get_num_pages() > 1) {
2832            if ($this->showall) {
2833                $html .= html_writer::link($this->attemptobj->review_url(null, 0, false),
2834                        get_string('showeachpage', 'quiz'));
2835            } else {
2836                $html .= html_writer::link($this->attemptobj->review_url(null, 0, true),
2837                        get_string('showall', 'quiz'));
2838            }
2839        }
2840        $html .= $output->finish_review_link($this->attemptobj);
2841        $html .= $this->render_restart_preview_link($output);
2842        return $html;
2843    }
2844}
2845