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