ad2bb5e3c25b1adf8efc5c00bf485763f9870798
[moodle.git] / mod / quiz / classes / structure.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  * Defines the \mod_quiz\structure class.
19  *
20  * @package   mod_quiz
21  * @copyright 2013 The Open University
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace mod_quiz;
26 defined('MOODLE_INTERNAL') || die();
28 /**
29  * Quiz structure class.
30  *
31  * The structure of the quiz. That is, which questions it is built up
32  * from. This is used on the Edit quiz page (edit.php) and also when
33  * starting an attempt at the quiz (startattempt.php). Once an attempt
34  * has been started, then the attempt holds the specific set of questions
35  * that that student should answer, and we no longer use this class.
36  *
37  * @copyright 2014 The Open University
38  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39  */
40 class structure {
41     /** @var \quiz the quiz this is the structure of. */
42     protected $quizobj = null;
44     /**
45      * @var \stdClass[] the questions in this quiz. Contains the row from the questions
46      * table, with the data from the quiz_slots table added, and also question_categories.contextid.
47      */
48     protected $questions = array();
50     /** @var \stdClass[] quiz_slots.id => the quiz_slots rows for this quiz, agumented by sectionid. */
51     protected $slots = array();
53     /** @var \stdClass[] quiz_slots.slot => the quiz_slots rows for this quiz, agumented by sectionid. */
54     protected $slotsinorder = array();
56     /**
57      * @var \stdClass[] currently a dummy. Holds data that will match the
58      * quiz_sections, once it exists.
59      */
60     protected $sections = array();
62     /** @var bool caches the results of can_be_edited. */
63     protected $canbeedited = null;
65     /**
66      * Create an instance of this class representing an empty quiz.
67      * @return structure
68      */
69     public static function create() {
70         return new self();
71     }
73     /**
74      * Create an instance of this class representing the structure of a given quiz.
75      * @param \stdClass $quiz the quiz settings.
76      * @return structure
77      */
78     public static function create_for($quiz) {
79         $structure = self::create();
80         $structure->populate_structure($quiz);
81         return $structure;
82     }
84     /**
85      * Create an instance of this class representing the structure of a given quiz.
86      * @param \quiz $quizobj the quiz.
87      * @return structure
88      */
89     public static function create_for_quiz($quizobj) {
90         $structure = self::create_for($quizobj->get_quiz());
91         $structure->quizobj = $quizobj;
92         return $structure;
93     }
95     /**
96      * Whether there are any questions in the quiz.
97      * @return bool true if there is at least one question in the quiz.
98      */
99     public function has_questions() {
100         return !empty($this->questions);
101     }
103     /**
104      * Get the number of questions in the quiz.
105      * @return int the number of questions in the quiz.
106      */
107     public function get_question_count() {
108         return count($this->questions);
109     }
111     /**
112      * Get the information about the question with this id.
113      * @param int $questionid The question id.
114      * @return \stdClass the data from the questions table, augmented with
115      * question_category.contextid, and the quiz_slots data for the question in this quiz.
116      */
117     public function get_question_by_id($questionid) {
118         return $this->questions[$questionid];
119     }
121     /**
122      * Get the information about the question in a given slot.
123      * @param int $slotnumber the index of the slot in question.
124      * @return \stdClass the data from the questions table, augmented with
125      * question_category.contextid, and the quiz_slots data for the question in this quiz.
126      */
127     public function get_question_in_slot($slotnumber) {
128         return $this->questions[$this->slotsinorder[$slotnumber]->questionid];
129     }
131     /**
132      * Get the course module id of the quiz.
133      * @return int the course_modules.id for the quiz.
134      */
135     public function get_cmid() {
136         return $this->quizobj->get_cmid();
137     }
139     /**
140      * Get id of the quiz.
141      * @return int the quiz.id for the quiz.
142      */
143     public function get_quizid() {
144         return $this->quizobj->get_quizid();
145     }
147     /**
148      * Get the quiz object.
149      * @return \stdClass the quiz settings row from the database.
150      */
151     public function get_quiz() {
152         return $this->quizobj->get_quiz();
153     }
155     /**
156      * Whether the question in the quiz are shuffled for each attempt.
157      * @return bool true if the questions are shuffled.
158      */
159     public function is_shuffled() {
160         return $this->quizobj->get_quiz()->shufflequestions;
161     }
163     /**
164      * Quizzes can only be repaginated if they have not been attempted, the
165      * questions are not shuffled, and there are two or more questions.
166      * @return bool whether this quiz can be repaginated.
167      */
168     public function can_be_repaginated() {
169         return !$this->is_shuffled() && $this->can_be_edited()
170                 && $this->get_question_count() >= 2;
171     }
173     /**
174      * Quizzes can only be edited if they have not been attempted.
175      * @return bool whether the quiz can be edited.
176      */
177     public function can_be_edited() {
178         if ($this->canbeedited === null) {
179             $this->canbeedited = !quiz_has_attempts($this->quizobj->get_quizid());
180         }
181         return $this->canbeedited;
182     }
184     /**
185      * This quiz can only be edited if they have not been attempted.
186      * Throw an exception if this is not the case.
187      */
188     public function check_can_be_edited() {
189         if (!$this->can_be_edited()) {
190             $reportlink = quiz_attempt_summary_link_to_reports($this->get_quiz(),
191                     $this->quizobj->get_cm(), $this->quizobj->get_context());
192             throw new \moodle_exception('cannoteditafterattempts', 'quiz',
193                     new \moodle_url('/mod/quiz/edit.php', array('cmid' => $this->get_cmid())), $reportlink);
194         }
195     }
197     /**
198      * How many questions are allowed per page in the quiz.
199      * This setting controls how frequently extra page-breaks should be inserted
200      * automatically when questions are added to the quiz.
201      * @return int the number of questions that should be on each page of the
202      * quiz by default.
203      */
204     public function get_questions_per_page() {
205         return $this->quizobj->get_quiz()->questionsperpage;
206     }
208     /**
209      * Get quiz slots.
210      * @return \stdClass[] the slots in this quiz.
211      */
212     public function get_slots() {
213         return $this->slots;
214     }
216     /**
217      * Is this slot the first one on its page?
218      * @param int $slotnumber the index of the slot in question.
219      * @return bool whether this slot the first one on its page.
220      */
221     public function is_first_slot_on_page($slotnumber) {
222         if ($slotnumber == 1) {
223             return true;
224         }
225         return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber - 1]->page;
226     }
228     /**
229      * Is this slot the last one on its page?
230      * @param int $slotnumber the index of the slot in question.
231      * @return bool whether this slot the last one on its page.
232      */
233     public function is_last_slot_on_page($slotnumber) {
234         if (!isset($this->slotsinorder[$slotnumber + 1])) {
235             return true;
236         }
237         return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber + 1]->page;
238     }
240     /**
241      * Is this slot the last one in the quiz?
242      * @param int $slotnumber the index of the slot in question.
243      * @return bool whether this slot the last one in the quiz.
244      */
245     public function is_last_slot_in_quiz($slotnumber) {
246         end($this->slotsinorder);
247         return $slotnumber == key($this->slotsinorder);
248     }
250     /**
251      * Get the final slot in the quiz.
252      * @return \stdClass the quiz_slots for for the final slot in the quiz.
253      */
254     public function get_last_slot() {
255         return end($this->slotsinorder);
256     }
258     /**
259      * Get a slot by it's id. Throws an exception if it is missing.
260      * @param int $slotid the slot id.
261      * @return \stdClass the requested quiz_slots row.
262      */
263     public function get_slot_by_id($slotid) {
264         if (!array_key_exists($slotid, $this->slots)) {
265             throw new \coding_exception('The \'slotid\' could not be found.');
266         }
267         return $this->slots[$slotid];
268     }
270     /**
271      * Get all the questions in a section of the quiz.
272      * @param int $sectionid the section id.
273      * @return \stdClass[] of question/slot objects.
274      */
275     public function get_questions_in_section($sectionid) {
276         $questions = array();
277         foreach ($this->slotsinorder as $slot) {
278             if ($slot->sectionid == $sectionid) {
279                 $questions[] = $this->questions[$slot->questionid];
280             }
281         }
282         return $questions;
283     }
285     /**
286      * Get all the sections of the quiz.
287      * @return \stdClass[] the sections in this quiz.
288      */
289     public function get_quiz_sections() {
290         return $this->sections;
291     }
293     /**
294      * Get any warnings to show at the top of the edit page.
295      * @return string[] array of strings.
296      */
297     public function get_edit_page_warnings() {
298         $warnings = array();
300         if (quiz_has_attempts($this->quizobj->get_quizid())) {
301             $reviewlink = quiz_attempt_summary_link_to_reports($this->quizobj->get_quiz(),
302                     $this->quizobj->get_cm(), $this->quizobj->get_context());
303             $warnings[] = get_string('cannoteditafterattempts', 'quiz', $reviewlink);
304         }
306         if ($this->is_shuffled()) {
307             $updateurl = new \moodle_url('/course/mod.php',
308                     array('return' => 'true', 'update' => $this->quizobj->get_cmid(), 'sesskey' => sesskey()));
309             $updatelink = '<a href="'.$updateurl->out().'">' . get_string('updatethis', '',
310                     get_string('modulename', 'quiz')) . '</a>';
311             $warnings[] = get_string('shufflequestionsselected', 'quiz', $updatelink);
312         }
314         return $warnings;
315     }
317     /**
318      * Get the date information about the current state of the quiz.
319      * @return string[] array of two strings. First a short summary, then a longer
320      * explanation of the current state, e.g. for a tool-tip.
321      */
322     public function get_dates_summary() {
323         $timenow = time();
324         $quiz = $this->quizobj->get_quiz();
326         // Exact open and close dates for the tool-tip.
327         $dates = array();
328         if ($quiz->timeopen > 0) {
329             if ($timenow > $quiz->timeopen) {
330                 $dates[] = get_string('quizopenedon', 'quiz', userdate($quiz->timeopen));
331             } else {
332                 $dates[] = get_string('quizwillopen', 'quiz', userdate($quiz->timeopen));
333             }
334         }
335         if ($quiz->timeclose > 0) {
336             if ($timenow > $quiz->timeclose) {
337                 $dates[] = get_string('quizclosed', 'quiz', userdate($quiz->timeclose));
338             } else {
339                 $dates[] = get_string('quizcloseson', 'quiz', userdate($quiz->timeclose));
340             }
341         }
342         if (empty($dates)) {
343             $dates[] = get_string('alwaysavailable', 'quiz');
344         }
345         $explanation = implode(', ', $dates);
347         // Brief summary on the page.
348         if ($timenow < $quiz->timeopen) {
349             $currentstatus = get_string('quizisclosedwillopen', 'quiz',
350                     userdate($quiz->timeopen, get_string('strftimedatetimeshort', 'langconfig')));
351         } else if ($quiz->timeclose && $timenow <= $quiz->timeclose) {
352             $currentstatus = get_string('quizisopenwillclose', 'quiz',
353                     userdate($quiz->timeclose, get_string('strftimedatetimeshort', 'langconfig')));
354         } else if ($quiz->timeclose && $timenow > $quiz->timeclose) {
355             $currentstatus = get_string('quizisclosed', 'quiz');
356         } else {
357             $currentstatus = get_string('quizisopen', 'quiz');
358         }
360         return array($currentstatus, $explanation);
361     }
363     /**
364      * Set up this class with the structure for a given quiz.
365      * @param \stdClass $quiz the quiz settings.
366      */
367     public function populate_structure($quiz) {
368         global $DB;
370         $slots = $DB->get_records_sql("
371                 SELECT slot.id AS slotid, slot.slot, slot.questionid, slot.page, slot.maxmark,
372                        q.*, qc.contextid
373                   FROM {quiz_slots} slot
374                   LEFT JOIN {question} q ON q.id = slot.questionid
375                   LEFT JOIN {question_categories} qc ON qc.id = q.category
376                  WHERE slot.quizid = ?
377               ORDER BY slot.slot", array($quiz->id));
379         $slots = $this->populate_missing_questions($slots);
381         $this->questions = array();
382         $this->slots = array();
383         $this->slotsinorder = array();
384         foreach ($slots as $slotdata) {
385             $this->questions[$slotdata->questionid] = $slotdata;
387             $slot = new \stdClass();
388             $slot->id = $slotdata->slotid;
389             $slot->slot = $slotdata->slot;
390             $slot->quizid = $quiz->id;
391             $slot->page = $slotdata->page;
392             $slot->questionid = $slotdata->questionid;
393             $slot->maxmark = $slotdata->maxmark;
395             $this->slots[$slot->id] = $slot;
396             $this->slotsinorder[$slot->slot] = $slot;
397         }
399         $section = new \stdClass();
400         $section->id = 1;
401         $section->quizid = $quiz->id;
402         $section->heading = '';
403         $section->firstslot = 1;
404         $section->shuffle = false;
405         $this->sections = array(1 => $section);
407         $this->populate_slots_with_sectionids();
408         $this->populate_question_numbers();
409     }
411     /**
412      * Used by populate. Make up fake data for any missing questions.
413      * @param \stdClass[] $slots the data about the slots and questions in the quiz.
414      * @return \stdClass[] updated $slots array.
415      */
416     protected function populate_missing_questions($slots) {
417         // Address missing question types.
418         foreach ($slots as $slot) {
419             if ($slot->qtype === null) {
420                 // If the questiontype is missing change the question type.
421                 $slot->id = $slot->questionid;
422                 $slot->category = 0;
423                 $slot->qtype = 'missingtype';
424                 $slot->name = get_string('missingquestion', 'quiz');
425                 $slot->slot = $slot->slot;
426                 $slot->maxmark = 0;
427                 $slot->questiontext = ' ';
428                 $slot->questiontextformat = FORMAT_HTML;
429                 $slot->length = 1;
431             } else if (!\question_bank::qtype_exists($slot->qtype)) {
432                 $slot->qtype = 'missingtype';
433             }
434         }
436         return $slots;
437     }
439     /**
440      * Fill in the section ids for each slot.
441      */
442     public function populate_slots_with_sectionids() {
443         $nextsection = reset($this->sections);
444         foreach ($this->slotsinorder as $slot) {
445             if ($slot->slot == $nextsection->firstslot) {
446                 $currentsectionid = $nextsection->id;
447                 $nextsection = next($this->sections);
448                 if (!$nextsection) {
449                     $nextsection = new \stdClass();
450                     $nextsection->firstslot = -1;
451                 }
452             }
454             $slot->sectionid = $currentsectionid;
455         }
456     }
458     /**
459      * Number the questions.
460      */
461     protected function populate_question_numbers() {
462         $number = 1;
463         foreach ($this->slots as $slot) {
464             $question = $this->questions[$slot->questionid];
465             if ($question->length == 0) {
466                 $question->displayednumber = get_string('infoshort', 'quiz');
467             } else {
468                 $question->displayednumber = $number;
469                 $number += 1;
470             }
471         }
472     }
474     /**
475      * Move a slot from its current location to a new location.
476      *
477      * After callig this method, this class will be in an invalid state, and
478      * should be discarded if you want to manipulate the structure further.
479      *
480      * @param int $idmove id of slot to be moved
481      * @param int $idbefore id of slot to come before slot being moved
482      * @param int $page new page number of slot being moved
483      * @return void
484      */
485     public function move_slot($idmove, $idbefore, $page) {
486         global $DB;
488         $this->check_can_be_edited();
490         $movingslot = $this->slots[$idmove];
491         if (empty($movingslot)) {
492             throw new moodle_exception('Bad slot ID ' . $idmove);
493         }
494         $movingslotnumber = (int) $movingslot->slot;
496         // Empty target slot means move slot to first.
497         if (empty($idbefore)) {
498             $targetslotnumber = 0;
499         } else {
500             $targetslotnumber = (int) $this->slots[$idbefore]->slot;
501         }
503         // Work out how things are being moved.
504         $slotreorder = array();
505         if ($targetslotnumber > $movingslotnumber) {
506             $slotreorder[$movingslotnumber] = $targetslotnumber;
507             for ($i = $movingslotnumber; $i < $targetslotnumber; $i++) {
508                 $slotreorder[$i + 1] = $i;
509             }
510         } else if ($targetslotnumber < $movingslotnumber - 1) {
511             $slotreorder[$movingslotnumber] = $targetslotnumber + 1;
512             for ($i = $targetslotnumber + 1; $i < $movingslotnumber; $i++) {
513                 $slotreorder[$i] = $i + 1;
514             }
515         }
517         $trans = $DB->start_delegated_transaction();
519         // Slot has moved record new order.
520         if ($slotreorder) {
521             update_field_with_unique_index('quiz_slots', 'slot', $slotreorder,
522                     array('quizid' => $this->get_quizid()));
523         }
525         // Page has changed. Record it.
526         if (!$page) {
527             $page = 1;
528         }
529         if ($movingslot->page != $page) {
530             $DB->set_field('quiz_slots', 'page', $page,
531                     array('id' => $movingslot->id));
532         }
534         $emptypages = $DB->get_fieldset_sql("
535                 SELECT DISTINCT page - 1
536                   FROM {quiz_slots} slot
537                  WHERE quizid = ?
538                    AND page > 1
539                    AND NOT EXISTS (SELECT 1 FROM {quiz_slots} WHERE quizid = ? AND page = slot.page - 1)
540               ORDER BY page - 1 DESC
541                 ", array($this->get_quizid(), $this->get_quizid()));
543         foreach ($emptypages as $page) {
544             $DB->execute("
545                     UPDATE {quiz_slots}
546                        SET page = page - 1
547                      WHERE quizid = ?
548                        AND page > ?
549                     ", array($this->get_quizid(), $page));
550         }
552         $trans->allow_commit();
553     }
555     /**
556      * Refresh page numbering of quiz slots.
557      * @param \stdClass $quiz the quiz object.
558      * @param \stdClass[] $slots (optional) array of slot objects.
559      * @return \stdClass[] array of slot objects.
560      */
561     public function refresh_page_numbers($quiz, $slots=array()) {
562         global $DB;
563         // Get slots ordered by page then slot.
564         if (!count($slots)) {
565             $slots = $DB->get_records('quiz_slots', array('quizid' => $quiz->id), 'slot, page');
566         }
568         // Loop slots. Start Page number at 1 and increment as required.
569         $pagenumbers = array('new' => 0, 'old' => 0);
571         foreach ($slots as $slot) {
572             if ($slot->page !== $pagenumbers['old']) {
573                 $pagenumbers['old'] = $slot->page;
574                 ++$pagenumbers['new'];
575             }
577             if ($pagenumbers['new'] == $slot->page) {
578                 continue;
579             }
580             $slot->page = $pagenumbers['new'];
581         }
583         return $slots;
584     }
586     /**
587      * Refresh page numbering of quiz slots and save to the database.
588      * @param \stdClass $quiz the quiz object.
589      * @return \stdClass[] array of slot objects.
590      */
591     public function refresh_page_numbers_and_update_db($quiz) {
592         global $DB;
593         $this->check_can_be_edited();
595         $slots = $this->refresh_page_numbers($quiz);
597         // Record new page order.
598         foreach ($slots as $slot) {
599             $DB->set_field('quiz_slots', 'page', $slot->page,
600                     array('id' => $slot->id));
601         }
603         return $slots;
604     }
606     /**
607      * Remove a slot from a quiz
608      * @param \stdClass $quiz the quiz object.
609      * @param int $slotnumber The number of the slot to be deleted.
610      */
611     public function remove_slot($quiz, $slotnumber) {
612         global $DB;
614         $this->check_can_be_edited();
616         $slot = $DB->get_record('quiz_slots', array('quizid' => $quiz->id, 'slot' => $slotnumber));
617         $maxslot = $DB->get_field_sql('SELECT MAX(slot) FROM {quiz_slots} WHERE quizid = ?', array($quiz->id));
618         if (!$slot) {
619             return;
620         }
622         $trans = $DB->start_delegated_transaction();
623         $DB->delete_records('quiz_slots', array('id' => $slot->id));
624         for ($i = $slot->slot + 1; $i <= $maxslot; $i++) {
625             $DB->set_field('quiz_slots', 'slot', $i - 1,
626                     array('quizid' => $quiz->id, 'slot' => $i));
627         }
629         $qtype = $DB->get_field('question', 'qtype', array('id' => $slot->questionid));
630         if ($qtype === 'random') {
631             // This function automatically checks if the question is in use, and won't delete if it is.
632             question_delete_question($slot->questionid);
633         }
635         unset($this->questions[$slot->questionid]);
637         $this->refresh_page_numbers_and_update_db($quiz);
639         $trans->allow_commit();
640     }
642     /**
643      * Change the max mark for a slot.
644      *
645      * Saves changes to the question grades in the quiz_slots table and any
646      * corresponding question_attempts.
647      * It does not update 'sumgrades' in the quiz table.
648      *
649      * @param \stdClass $slot row from the quiz_slots table.
650      * @param float $maxmark the new maxmark.
651      * @return bool true if the new grade is different from the old one.
652      */
653     public function update_slot_maxmark($slot, $maxmark) {
654         global $DB;
656         if (abs($maxmark - $slot->maxmark) < 1e-7) {
657             // Grade has not changed. Nothing to do.
658             return false;
659         }
661         $trans = $DB->start_delegated_transaction();
662         $slot->maxmark = $maxmark;
663         $DB->update_record('quiz_slots', $slot);
664         \question_engine::set_max_mark_in_attempts(new \qubaids_for_quiz($slot->quizid),
665                 $slot->slot, $maxmark);
666         $trans->allow_commit();
668         return true;
669     }
671     /**
672      * Add/Remove a pagebreak.
673      *
674      * Saves changes to the slot page relationship in the quiz_slots table and reorders the paging
675      * for subsequent slots.
676      *
677      * @param \stdClass $quiz the quiz object.
678      * @param int $slotid id of slot.
679      * @param int $type repaginate::LINK or repaginate::UNLINK.
680      * @return \stdClass[] array of slot objects.
681      */
682     public function update_page_break($quiz, $slotid, $type) {
683         global $DB;
685         $this->check_can_be_edited();
687         $quizslots = $DB->get_records('quiz_slots', array('quizid' => $quiz->id), 'slot');
688         $repaginate = new \mod_quiz\repaginate($quiz->id, $quizslots);
689         $repaginate->repaginate_slots($quizslots[$slotid]->slot, $type);
690         $slots = $this->refresh_page_numbers_and_update_db($quiz);
692         return $slots;
693     }