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