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