MDL-11905 - Users with the capability to export questions may not have the capability...
[moodle.git] / mod / quiz / attemptlib.php
CommitLineData
36e413e3 1<?php
36e413e3 2/**
3 * This class handles loading all the information about a quiz attempt into memory,
4 * and making it available for attemtp.php, summary.php and review.php.
5 * Initially, it only loads a minimal amout of information about each attempt - loading
6 * extra information only when necessary or when asked. The class tracks which questions
7 * are loaded.
b10c38a3 8 *//** */
36e413e3 9
10require_once("../../config.php");
11
12/**
b10c38a3 13 * Class for quiz exceptions. Just saves a couple of arguments on the
14 * constructor for a moodle_exception.
36e413e3 15 */
16class moodle_quiz_exception extends moodle_exception {
17 function __construct($quizobj, $errorcode, $a = NULL, $link = '', $debuginfo = null) {
18 if (!$link) {
19 $link = $quizobj->view_url();
20 }
21 parent::__construct($errorcode, 'quiz', $link, $a, $debuginfo);
22 }
23}
24
b10c38a3 25/**
26 * A base class for holding and accessing information about a quiz and its questions,
27 * before details of a particular attempt are loaded.
28 */
36e413e3 29class quiz {
30 // Fields initialised in the constructor.
31 protected $course;
32 protected $cm;
33 protected $quiz;
34 protected $context;
35
36 // Fields set later if that data is needed.
37 protected $accessmanager = null;
38 protected $reviewoptions = null;
b10c38a3 39 protected $ispreviewuser = null;
36e413e3 40 protected $questions = array();
41 protected $questionsnumbered = false;
42
43 // Constructor =========================================================================
b10c38a3 44 /**
45 * Constructor, assuming we already have the necessary data loaded.
46 *
47 * @param object $quiz the row from the quiz table.
48 * @param object $cm the course_module object for this quiz.
49 * @param object $course the row from the course table for the course we belong to.
50 */
36e413e3 51 function __construct($quiz, $cm, $course) {
52 $this->quiz = $quiz;
53 $this->cm = $cm;
54 $this->course = $course;
55 $this->context = get_context_instance(CONTEXT_MODULE, $cm->id);
56 }
57
58 // Functions for loading more data =====================================================
59 public function load_questions_on_page($page) {
60 $this->load_questions(quiz_questions_on_page($this->quiz->layout, $page));
61 }
62
63 /**
64 * Load some or all of the queestions for this quiz.
65 *
66 * @param string $questionlist comma-separate list of question ids. Blank for all.
67 */
68 public function load_questions($questionlist = '') {
69 if (!$questionlist) {
70 $questionlist = quiz_questions_in_quiz($this->quiz->layout);
71 }
72 $newquestions = question_load_questions($questionlist, 'qqi.grade AS maxgrade, qqi.id AS instance',
73 '{quiz_question_instances} qqi ON qqi.quiz = ' . $this->quiz->id . ' AND q.id = qqi.question');
74 if (is_string($newquestions)) {
75 throw new moodle_quiz_exception($this, 'loadingquestionsfailed', $newquestions);
76 }
77 $this->questions = $this->questions + $newquestions;
78 $this->questionsnumbered = false;
79 }
80
81 // Simple getters ======================================================================
b10c38a3 82 /** @return integer the course id. */
36e413e3 83 public function get_courseid() {
84 return $this->course->id;
85 }
86
b10c38a3 87 /** @return integer the quiz id. */
36e413e3 88 public function get_quizid() {
89 return $this->quiz->id;
90 }
91
b10c38a3 92 /** @return object the row of the quiz table. */
36e413e3 93 public function get_quiz() {
94 return $this->quiz;
95 }
96
b10c38a3 97 /** @return string the name of this quiz. */
36e413e3 98 public function get_quiz_name() {
99 return $this->quiz->name;
100 }
101
b10c38a3 102 /** @return integer the course_module id. */
36e413e3 103 public function get_cmid() {
104 return $this->cm->id;
105 }
106
b10c38a3 107 /** @return object the course_module object. */
36e413e3 108 public function get_cm() {
109 return $this->cm;
110 }
111
b10c38a3 112 /**
113 * @return boolean wether the current user is someone who previews the quiz,
114 * rather than attempting it.
115 */
116 public function is_preview_user() {
117 if (is_null($this->ispreviewuser)) {
118 $this->ispreviewuser = has_capability('mod/quiz:preview', $this->context);
119 }
120 return $this->ispreviewuser;
121 }
122
123 /**
124 * @param integer $id the question id.
125 * @return object the question object with that id.
126 */
36e413e3 127 public function get_question($id) {
128 $this->ensure_question_loaded($id);
129 return $this->questions[$id];
130 }
131
b10c38a3 132 /**
133 * @param integer $timenow the current time as a unix timestamp.
134 * @return object and instance of the quiz_access_manager class for this quiz at this time.
135 */
36e413e3 136 public function get_access_manager($timenow) {
137 if (is_null($this->accessmanager)) {
138 $this->accessmanager = new quiz_access_manager($this->quiz, $timenow,
139 has_capability('mod/quiz:ignoretimelimits', $this->context, NULL, false));
140 }
141 return $this->accessmanager;
142 }
143
144 // URLs related to this attempt ========================================================
b10c38a3 145 /**
146 * @return string the URL of this quiz's view page.
147 */
36e413e3 148 public function view_url() {
149 global $CFG;
150 return $CFG->wwwroot . '/mod/quiz/view.php?id=' . $this->cm->id;
151 }
152
153 // Bits of content =====================================================================
b10c38a3 154 /**
155 * @return string the HTML snipped that needs to be supplied to print_header_simple
156 * as the $button parameter.
157 */
36e413e3 158 public function update_module_button() {
159 if (has_capability('moodle/course:manageactivities',
160 get_context_instance(CONTEXT_COURSE, $this->course->id))) {
161 return update_module_button($this->cm->id, $this->course->id, get_string('modulename', 'quiz'));
162 } else {
163 return '';
164 }
165 }
166
b10c38a3 167 /**
168 * @param string $title the name of this particular quiz page.
169 * @return array the data that needs to be sent to print_header_simple as the $navigation
170 * parameter.
171 */
36e413e3 172 public function navigation($title) {
173 return build_navigation($title, $this->cm);
174 }
175
176 // Private methods =====================================================================
b10c38a3 177 // Check that the definition of a particular question is loaded, and if not throw an exception.
36e413e3 178 private function ensure_question_loaded($id) {
179 if (!array_key_exists($id, $this->questions)) {
180 throw new moodle_quiz_exception($this, 'questionnotloaded', $id);
181 }
182 }
183}
184
b10c38a3 185/**
186 * This class extends the quiz class to hold data about the state of a particular attempt,
187 * in addition to the data about the quiz.
188 */
36e413e3 189class quiz_attempt extends quiz {
190 // Fields initialised in the constructor.
191 protected $attempt;
192
193 // Fields set later if that data is needed.
36e413e3 194 protected $states = array();
195
196 // Constructor =========================================================================
b10c38a3 197 /**
198 * Constructor from just an attemptid.
199 *
200 * @param integer $attemptid the id of the attempt to load. We automatically load the
201 * associated quiz, course, etc.
202 */
36e413e3 203 function __construct($attemptid) {
204 global $DB;
205 if (!$this->attempt = quiz_load_attempt($attemptid)) {
206 throw new moodle_exception('invalidattemptid', 'quiz');
207 }
208 if (!$quiz = $DB->get_record('quiz', array('id' => $this->attempt->quiz))) {
209 throw new moodle_exception('invalidquizid', 'quiz');
210 }
211 if (!$course = $DB->get_record('course', array('id' => $quiz->course))) {
212 throw new moodle_exception('invalidcoursemodule');
213 }
214 if (!$cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id)) {
215 throw new moodle_exception('invalidcoursemodule');
216 }
217 parent::__construct($quiz, $cm, $course);
218 }
219
220 // Functions for loading more data =====================================================
221 public function load_questions_on_page($page) {
222 $this->load_questions(quiz_questions_on_page($this->attempt->layout, $page));
223 }
224
225 /**
226 * Load some or all of the queestions for this quiz.
227 *
228 * @param string $questionlist comma-separate list of question ids. Blank for all.
229 */
230 public function load_questions($questionlist = '') {
231 if (!$questionlist) {
232 $questionlist = quiz_questions_in_quiz($this->attempt->layout);
233 }
234 parent::load_questions($questionlist);
235 }
236
237 public function load_question_states() {
238 $questionstodo = array_diff_key($this->questions, $this->states);
239 if (!$newstates = get_question_states($questionstodo, $this->quiz, $this->attempt)) {
240 throw new moodle_quiz_exception($this, 'cannotrestore');
241 }
242 $this->states = $this->states + $newstates;
243 }
244
245 /**
b10c38a3 246 * Number the loaded questions.
36e413e3 247 *
248 * At the moment, this assumes for simplicity that the loaded questions are contiguous.
249 */
250 public function number_questions($page = 'all') {
251 if ($this->questionsnumbered) {
252 return;
253 }
254 if ($page != 'all') {
255 $pagelist = quiz_questions_in_page($this->attempt->layout, $page);
256 $number = quiz_first_questionnumber($this->attempt->layout, $pagelist);
257 } else {
258 $number = 1;
259 }
260 $questionids = $this->get_question_ids($page);
261 foreach ($questionids as $id) {
262 if ($this->questions[$id]->length > 0) {
263 $this->questions[$id]->number = $number;
264 $number += $this->questions[$id]->length;
265 } else {
266 $this->questions[$id]->number = get_string('infoshort', 'quiz');
267 }
268 }
269 }
270
271 // Simple getters ======================================================================
b10c38a3 272 /** @return integer the attempt id. */
36e413e3 273 public function get_attemptid() {
274 return $this->attempt->id;
275 }
276
b10c38a3 277 /** @return object the row from the quiz_attempts table. */
36e413e3 278 public function get_attempt() {
279 return $this->attempt;
280 }
281
b10c38a3 282 /** @return integer the id of the user this attempt belongs to. */
36e413e3 283 public function get_userid() {
284 return $this->attempt->userid;
285 }
286
b10c38a3 287 /** @return boolean whether this attemp has been finished (true) or is still in progress (false). */
36e413e3 288 public function is_finished() {
289 return $this->attempt->timefinish != 0;
290 }
291
b10c38a3 292 /**
293 * Wrapper that calls quiz_get_reviewoptions with the appropriate arguments.
294 *
295 * @return object the review optoins for this user on this attempt.
296 */
36e413e3 297 public function get_review_options() {
298 if (is_null($this->reviewoptions)) {
299 $this->reviewoptions = quiz_get_reviewoptions($this->quiz, $this->attempt, $this->context);
300 }
301 return $this->reviewoptions;
302 }
303
b10c38a3 304 /**
305 * Return the list of question ids for either a given page of the quiz, or for the
306 * whole quiz.
307 *
308 * @param mixed $page string 'all' or integer page number.
309 * @return array the reqested list of question ids.
310 */
36e413e3 311 public function get_question_ids($page = 'all') {
312 if ($page == 'all') {
313 $questionlist = quiz_questions_in_quiz($this->attempt->layout);
314 } else {
315 $questionlist = quiz_questions_in_page($this->attempt->layout, $page);
316 }
317 return explode(',', $questionlist);
318 }
319
b10c38a3 320 /**
321 * Get a quiz_attempt_question_iterator for either a page of the quiz, or a whole quiz.
322 * You must have called load_questions with an appropriate argument first.
323 *
324 * @param mixed $page as for the @see{get_question_ids} method.
325 * @return quiz_attempt_question_iterator the requested iterator.
326 */
36e413e3 327 public function get_question_iterator($page = 'all') {
328 return new quiz_attempt_question_iterator($this, $page);
329 }
330
b10c38a3 331 /**
332 * Return a summary of the current state of a question in this attempt. You must previously
333 * have called load_question_states to load the state data about this question.
334 *
335 * @param integer $questionid question id of a question that belongs to this quiz.
336 * @return string a brief string (that could be used as a CSS class name, for example)
337 * that describes the current state of a question in this attempt. Possible results are:
338 * open|saved|closed|correct|partiallycorrect|incorrect.
339 */
36e413e3 340 public function get_question_status($questionid) {
b10c38a3 341 $this->ensure_state_loaded($questionid);
342 $state = $this->states[$questionid];
343 switch ($state->event) {
344 case QUESTION_EVENTOPEN:
345 return 'open';
346
347 case QUESTION_EVENTSAVE:
348 case QUESTION_EVENTGRADE:
349 return 'saved';
350
351 case QUESTION_EVENTCLOSEANDGRADE:
352 case QUESTION_EVENTCLOSE:
353 case QUESTION_EVENTMANUALGRADE:
354 $options = quiz_get_renderoptions($this->quiz->review, $this->states[$questionid]);
355 if ($options->scores) {
356 return question_get_feedback_class($state->last_graded->raw_grade /
357 $this->questions[$questionid]->maxgrade);
358 } else {
359 return 'closed';
360 }
361
362 default:
363 $a = new stdClass;
364 $a->event = $state->event;
365 $a->questionid = $questionid;
366 $a->attemptid = $this->attempt->id;
367 throw new moodle_quiz_exception($this, 'errorunexpectedevent', $a);
368 }
36e413e3 369 }
370
371 /**
b10c38a3 372 * Return the grade obtained on a particular question, if the user is permitted to see it.
373 * You must previously have called load_question_states to load the state data about this question.
36e413e3 374 *
b10c38a3 375 * @param integer $questionid question id of a question that belongs to this quiz.
36e413e3 376 * @return string the formatted grade, to the number of decimal places specified by the quiz.
377 */
378 public function get_question_score($questionid) {
379 $this->ensure_state_loaded($questionid);
380 $options = quiz_get_renderoptions($this->quiz->review, $this->states[$questionid]);
381 if ($options->scores) {
382 return round($this->states[$questionid]->last_graded->grade, $this->quiz->decimalpoints);
383 } else {
384 return '';
385 }
386 }
387
388 // URLs related to this attempt ========================================================
b10c38a3 389 /**
390 * @param integer $page if specified, the URL of this particular page of the attempt, otherwise
391 * the URL will go to the first page.
392 * @param integer $question a question id. If set, will add a fragment to the URL
393 * to jump to a particuar question on the page.
394 * @return string the URL to continue this attempt.
395 */
36e413e3 396 public function attempt_url($page = 0, $question = false) {
397 global $CFG;
398 $fragment = '';
399 if ($question) {
400 $fragment = '#q' . $question;
401 }
402 return $CFG->wwwroot . '/mod/quiz/attempt.php?id=' .
403 $this->cm->id . '$amp;page=' . $page . $fragment;
404 }
405
b10c38a3 406 /**
407 * @return string the URL of this quiz's summary page.
408 */
36e413e3 409 public function summary_url() {
410 global $CFG;
411 return $CFG->wwwroot . '/mod/quiz/summary.php?attempt=' . $this->attempt->id;
412 }
413
b10c38a3 414 /**
415 * @param integer $page if specified, the URL of this particular page of the attempt, otherwise
416 * the URL will go to the first page.
417 * @param integer $question a question id. If set, will add a fragment to the URL
418 * to jump to a particuar question on the page.
419 * @param boolean $showall if true, the URL will be to review the entire attempt on one page,
420 * and $page will be ignored.
421 * @return string the URL to review this attempt.
422 */
36e413e3 423 public function review_url($page = 0, $question = false, $showall = false) {
424 global $CFG;
425 $fragment = '';
426 if ($question) {
427 $fragment = '#q' . $question;
428 }
429 $param = '';
430 if ($showall) {
431 $param = '$amp;showall=1';
432 } else if ($page) {
433 $param = '$amp;page=' . $page;
434 }
435 return $CFG->wwwroot . '/mod/quiz/review.php?attempt=' .
436 $this->attempt->id . $param . $fragment;
437 }
438
439
440 // Private methods =====================================================================
b10c38a3 441 // Check that the state of a particular question is loaded, and if not throw an exception.
36e413e3 442 private function ensure_state_loaded($id) {
443 if (!array_key_exists($id, $this->states)) {
444 throw new moodle_quiz_exception($this, 'statenotloaded', $id);
445 }
446 }
447}
448
b10c38a3 449/**
450 * A PHP Iterator for conviniently looping over the questions in a quiz. The keys are the question
451 * numbers (with 'i' for descriptions) and the values are the question objects.
452 */
36e413e3 453class quiz_attempt_question_iterator implements Iterator {
b10c38a3 454 private $attemptobj; // Reference to the quiz_attempt object we provide access to.
455 private $questionids; // Array of the question ids within that attempt we are iterating over.
456
457 /**
458 * Constructor. Normally, you don't want to call this directly. Instead call
459 * quiz_attempt::get_question_iterator
460 *
461 * @param quiz_attempt $attemptobj the quiz_attempt object we will be providing access to.
462 * @param mixed $page as for @see{quiz_attempt::get_question_iterator}.
463 */
36e413e3 464 public function __construct(quiz_attempt $attemptobj, $page = 'all') {
465 $this->attemptobj = $attemptobj;
466 $attemptobj->number_questions($page);
467 $this->questionids = $attemptobj->get_question_ids($page);
468 }
469
b10c38a3 470 // Implementation of the Iterator interface ============================================
36e413e3 471 public function rewind() {
472 reset($this->questionids);
473 }
474
475 public function current() {
476 $id = current($this->questionids);
477 if ($id) {
478 return $this->attemptobj->get_question($id);
479 } else {
480 return false;
481 }
482 }
483
484 public function key() {
485 $id = current($this->questionids);
486 if ($id) {
487 return $this->attemptobj->get_question($id)->number;
488 } else {
489 return false;
490 }
491 return $this->attemptobj->get_question(current($this->questionids))->number;
492 }
493
494 public function next() {
495 $id = next($this->questionids);
496 if ($id) {
497 return $this->attemptobj->get_question($id);
498 } else {
499 return false;
500 }
501 }
502
503 public function valid() {
504 return $this->current() !== false;
505 }
506}
507?>