questiontypebase.php 64.4 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?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/>.

17
/**
18
19
 * The default questiontype class.
 *
20
 * @package    moodlecore
21
 * @subpackage questiontypes
22
23
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24
 */
25

26

27
28
defined('MOODLE_INTERNAL') || die();

29
require_once($CFG->dirroot . '/question/engine/lib.php');
30

31

tjhunt's avatar
tjhunt committed
32
33
/**
 * This is the base class for Moodle question types.
34
 *
tjhunt's avatar
tjhunt committed
35
36
 * There are detailed comments on each method, explaining what the method is
 * for, and the circumstances under which you might need to override it.
37
 *
tjhunt's avatar
tjhunt committed
38
 * Note: the questiontype API should NOT be considered stable yet. Very few
39
 * question types have been produced yet, so we do not yet know all the places
40
 * where the current API is insufficient. I would rather learn from the
tjhunt's avatar
tjhunt committed
41
42
43
 * experiences of the first few question type implementors, and improve the
 * interface to meet their needs, rather the freeze the API prematurely and
 * condem everyone to working round a clunky interface for ever afterwards.
44
 *
45
46
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
tjhunt's avatar
tjhunt committed
47
 */
48
class question_type {
49
    protected $fileoptions = array(
50
        'subdirs' => true,
51
52
53
        'maxfiles' => -1,
        'maxbytes' => 0,
    );
54

55
    public function __construct() {
56
57
    }

tjhunt's avatar
tjhunt committed
58
    /**
59
     * @return string the name of this question type.
60
     */
61
62
    public function name() {
        return substr(get_class($this), 6);
63
    }
64

65
66
67
68
69
70
71
72
73
74
75
76
    /**
     * @return string the full frankenstyle name for this plugin.
     */
    public function plugin_name() {
        return get_class($this);
    }

    /**
     * @return string the name of this question type in the user's language.
     * You should not need to override this method, the default behaviour should be fine.
     */
    public function local_name() {
77
        return get_string('pluginname', $this->plugin_name());
78
79
    }

80
81
    /**
     * The name this question should appear as in the create new question
82
83
84
     * dropdown. Override this method to return false if you don't want your
     * question type to be createable, for example if it is an abstract base type,
     * otherwise, you should not need to override this method.
85
86
87
     *
     * @return mixed the desired string, or false to hide this question type in the menu.
     */
88
    public function menu_name() {
89
90
91
92
        return $this->local_name();
    }

    /**
93
     * @return bool override this to return false if this is not really a
94
95
96
97
98
     *      question type, for example the description question type is not
     *      really a question type.
     */
    public function is_real_question_type() {
        return true;
99
100
    }

101
    /**
102
     * @return bool true if this question type sometimes requires manual grading.
103
     */
104
    public function is_manual_graded() {
105
106
107
        return false;
    }

108
109
110
    /**
     * @param object $question a question of this type.
     * @param string $otherquestionsinuse comma-separate list of other question ids in this attempt.
111
     * @return bool true if a particular instance of this question requires manual grading.
112
     */
113
    public function is_question_manual_graded($question, $otherquestionsinuse) {
114
115
116
        return $this->is_manual_graded();
    }

117
    /**
118
     * @return bool true if this question type can be used by the random question type.
119
     */
120
121
    public function is_usable_by_random() {
        return true;
122
123
    }

124
    /**
125
126
127
128
129
130
131
     * Whether this question type can perform a frequency analysis of student
     * responses.
     *
     * If this method returns true, you must implement the get_possible_responses
     * method, and the question_definition class must implement the
     * classify_response method.
     *
132
     * @return bool whether this report can analyse all the student responses
133
     * for things like the quiz statistics report.
134
     */
135
136
137
    public function can_analyse_responses() {
        // This works in most cases.
        return !$this->is_manual_graded();
138
139
    }

140
    /**
141
142
     * @return whether the question_answers.answer field needs to have
     * restore_decode_content_links_worker called on it.
143
     */
144
    public function has_html_answers() {
145
146
        return false;
    }
147

148
149
150
151
152
153
154
155
    /**
     * If your question type has a table that extends the question table, and
     * you want the base class to automatically save, backup and restore the extra fields,
     * override this method to return an array wherer the first element is the table name,
     * and the subsequent entries are the column names (apart from id and questionid).
     *
     * @return mixed array as above, or null to tell the base class to do nothing.
     */
156
    public function extra_question_fields() {
157
158
159
        return null;
    }

160
    /**
161
162
163
     * If you use extra_question_fields, overload this function to return question id field name
     *  in case you table use another name for this column
     */
164
    public function questionid_column_name() {
165
166
167
        return 'questionid';
    }

168
169
170
171
172
173
174
    /**
     * If your question type has a table that extends the question_answers table,
     * make this method return an array wherer the first element is the table name,
     * and the subsequent entries are the column names (apart from id and answerid).
     *
     * @return mixed array as above, or null to tell the base class to do nothing.
     */
175
    public function extra_answer_fields() {
176
177
178
179
180
181
182
183
184
185
186
187
188
        return null;
    }

    /**
     * If the quetsion type uses files in responses, then this method should
     * return an array of all the response variables that might have corresponding
     * files. For example, the essay qtype returns array('attachments', 'answers').
     *
     * @return array response variable names that may have associated files.
     */
    public function response_file_areas() {
        return array();
    }
189

190
191
192
    /**
     * Return an instance of the question editing form definition. This looks for a
     * class called edit_{$this->name()}_question_form in the file
193
     * question/type/{$this->name()}/edit_{$this->name()}_question_form.php
194
195
196
197
198
     * and if it exists returns an instance of it.
     *
     * @param string $submiturl passed on to the constructor call.
     * @return object an instance of the form definition, or null if one could not be found.
     */
199
200
    public function create_editing_form($submiturl, $question, $category,
            $contexts, $formeditable) {
201
        global $CFG;
202
203
        require_once($CFG->dirroot . '/question/type/edit_question_form.php');
        $definitionfile = $CFG->dirroot . '/question/type/' . $this->name() .
204
                '/edit_' . $this->name() . '_form.php';
205
206
        if (!is_readable($definitionfile) || !is_file($definitionfile)) {
            throw new coding_exception($this->plugin_name() .
207
208
                    ' is missing the definition of its editing formin file ' .
                    $definitionfile . '.');
209
        }
210
        require_once($definitionfile);
211
        $classname = $this->plugin_name() . '_edit_form';
212
        if (!class_exists($classname)) {
213
214
215
            throw new coding_exception($this->plugin_name() .
                    ' does not define the class ' . $this->plugin_name() .
                    '_edit_form.');
216
        }
217
        return new $classname($submiturl, $question, $category, $contexts, $formeditable);
218
219
    }

220
221
222
    /**
     * @return string the full path of the folder this plugin's files live in.
     */
223
    public function plugin_dir() {
224
225
226
227
228
229
230
        global $CFG;
        return $CFG->dirroot . '/question/type/' . $this->name();
    }

    /**
     * @return string the URL of the folder this plugin's files live in.
     */
231
    public function plugin_baseurl() {
232
233
234
235
        global $CFG;
        return $CFG->wwwroot . '/question/type/' . $this->name();
    }

236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
    /**
     * Get extra actions for a question of this type to add to the question bank edit menu.
     *
     * This method is called if the {@link edit_menu_column} is being used in the
     * question bank, which it is by default since Moodle 3.8. If applicable for
     * your question type, you can return arn array of {@link action_menu_link}s.
     * These will be added at the end of the Edit menu for this question.
     *
     * The $question object passed in will have a hard-to-predict set of fields,
     * because the fields present depend on which columns are included in the
     * question bank view. However, you can rely on 'id', 'createdby',
     * 'contextid', 'hidden' and 'category' (id) being present, and so you
     * can call question_has_capability_on without causing performance problems.
     *
     * @param stdClass $question the available information about the particular question the action is for.
     * @return action_menu_link[] any actions you want to add to the Edit menu for this question.
     */
    public function get_extra_question_bank_actions(stdClass $question): array {
        return [];
    }

257
258
259
260
261
262
263
264
    /**
     * This method should be overriden if you want to include a special heading or some other
     * html on a question editing page besides the question editing form.
     *
     * @param question_edit_form $mform a child of question_edit_form
     * @param object $question
     * @param string $wizardnow is '' for first page.
     */
265
266
267
    public function display_question_editing_page($mform, $question, $wizardnow) {
        global $OUTPUT;
        $heading = $this->get_heading(empty($question->id));
268
        echo $OUTPUT->heading_with_help($heading, 'pluginname', $this->plugin_name());
269
270
        $mform->display();
    }
271

272
    /**
273
274
     * Method called by display_question_editing_page and by question.php to get
     * heading for breadcrumbs.
275
     *
276
     * @return string the heading
277
     */
278
    public function get_heading($adding = false) {
279
        if ($adding) {
280
281
282
            $string = 'pluginnameadding';
        } else {
            $string = 'pluginnameediting';
283
        }
284
        return get_string($string, $this->plugin_name());
285
286
287
288
289
290
291
292
293
294
295
    }

    /**
     * Set any missing settings for this question to the default values. This is
     * called before displaying the question editing form.
     *
     * @param object $questiondata the question data, loaded from the databsae,
     *      or more likely a newly created question object that is only partially
     *      initialised.
     */
    public function set_default_options($questiondata) {
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
    /**
     * Return default value for a given form element either from user_preferences table or $default.
     *
     * @param string $name the name of the form element.
     * @param mixed $default default value.
     * @return string|null default value for a given  form element.
     */
    public function get_default_value(string $name, $default): ?string {
        return get_user_preferences($this->plugin_name() . '_' . $name, $default ?? '0');
    }

    /**
     * Save the default value for a given form element in user_preferences table.
     *
     * @param string $name the name of the value to set.
     * @param string $value the setting value.
     */
    public function set_default_value(string $name, string $value): void {
        set_user_preference($this->plugin_name() . '_' . $name, $value);
    }

    /**
     * Save question defaults when creating new questions.
     *
     * @param stdClass $fromform data from the form.
     */
    public function save_defaults_for_new_questions(stdClass $fromform): void {
        // Some question types may not make use of the certain form elements, so
        // we need to do a check on the following generic form elements. For instance,
        // 'defaultmark' is not use in qtype_multianswer and 'penalty' in not used in
        // qtype_essay and qtype_recordrtc.
        if (isset($fromform->defaultmark)) {
            $this->set_default_value('defaultmark', $fromform->defaultmark);
        }
        if (isset($fromform->penalty)) {
            $this->set_default_value('penalty', $fromform->penalty);
        }
    }

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
     * Saves (creates or updates) a question.
     *
     * Given some question info and some data about the answers
     * this function parses, organises and saves the question
     * It is used by {@link question.php} when saving new data from
     * a form, and also by {@link import.php} when importing questions
     * This function in turn calls {@link save_question_options}
     * to save question-type specific data.
     *
     * Whether we are saving a new question or updating an existing one can be
     * determined by testing !empty($question->id). If it is not empty, we are updating.
     *
     * The question will be saved in category $form->category.
     *
     * @param object $question the question object which should be updated. For a
     *      new question will be mostly empty.
     * @param object $form the object containing the information to save, as if
     *      from the question editing form.
     * @param object $course not really used any more.
     * @return object On success, return the new question object. On failure,
     *       return an object as follows. If the error object has an errors field,
     *       display that as an error message. Otherwise, the editing form will be
     *       redisplayed with validation errors, from validation_errors field, which
     *       is itself an object, shown next to the form fields. (I don't think this
     *       is accurate any more.)
     */
364
    public function save_question($question, $form) {
365
        global $USER, $DB;
366

367
        // The actual update/insert done with multiple DB access, so we do it in a transaction.
368
369
        $transaction = $DB->start_delegated_transaction ();

370
371
        list($question->category) = explode(',', $form->category);
        $context = $this->get_context_by_category_id($question->category);
372

373
374
        // This default implementation is suitable for most
        // question types.
375

376
        // First, save the basic question itself.
377
378
        $question->name = trim($form->name);
        $question->parent = isset($form->parent) ? $form->parent : 0;
379
380
381
        $question->length = $this->actual_number_of_questions($question);
        $question->penalty = isset($form->penalty) ? $form->penalty : 0;

382
383
384
385
        // The trim call below has the effect of casting any strange values received,
        // like null or false, to an appropriate string, so we only need to test for
        // missing values. Be careful not to break the value '0' here.
        if (!isset($form->questiontext['text'])) {
386
            $question->questiontext = '';
387
        } else {
388
            $question->questiontext = trim($form->questiontext['text']);
389
        }
390
391
        $question->questiontextformat = !empty($form->questiontext['format']) ?
                $form->questiontext['format'] : 0;
392

393
        if (empty($form->generalfeedback['text'])) {
394
            $question->generalfeedback = '';
395
        } else {
396
            $question->generalfeedback = trim($form->generalfeedback['text']);
397
        }
398
399
        $question->generalfeedbackformat = !empty($form->generalfeedback['format']) ?
                $form->generalfeedback['format'] : 0;
400

401
        if ($question->name === '') {
402
            $question->name = shorten_text(strip_tags($form->questiontext['text']), 15);
403
            if ($question->name === '') {
404
405
406
407
408
                $question->name = '-';
            }
        }

        if ($question->penalty > 1 or $question->penalty < 0) {
409
            $question->errors['penalty'] = get_string('invalidpenalty', 'question');
410
411
        }

412
413
        if (isset($form->defaultmark)) {
            $question->defaultmark = $form->defaultmark;
414
415
        }

416
417
418
        if (isset($form->idnumber)) {
            if ((string) $form->idnumber === '') {
                $question->idnumber = null;
419
            } else {
420
421
422
423
424
425
426
427
428
429
430
431
                // While this check already exists in the form validation,
                // this is a backstop preventing unnecessary errors.
                // Only set the idnumber if it has changed and will not cause a unique index violation.
                if (strpos($form->category, ',') !== false) {
                    list($category, $categorycontextid) = explode(',', $form->category);
                } else {
                    $category = $form->category;
                }
                if (!$DB->record_exists('question',
                        ['idnumber' => $form->idnumber, 'category' => $category])) {
                    $question->idnumber = $form->idnumber;
                }
432
433
434
            }
        }

435
        // If the question is new, create it.
436
        $newquestion = false;
437
        if (empty($question->id)) {
438
            // Set the unique code.
439
            $question->stamp = make_unique_id_code();
440
441
            $question->createdby = $USER->id;
            $question->timecreated = time();
442
            $question->id = $DB->insert_record('question', $question);
443
            $newquestion = true;
444
445
        }

446
447
        // Now, whether we are updating a existing question, or creating a new
        // one, we have to do the files processing and update the record.
448
        // Question already exists, update.
449
450
451
452
        $question->modifiedby = $USER->id;
        $question->timemodified = time();

        if (!empty($question->questiontext) && !empty($form->questiontext['itemid'])) {
453
454
455
            $question->questiontext = file_save_draft_area_files($form->questiontext['itemid'],
                    $context->id, 'question', 'questiontext', (int)$question->id,
                    $this->fileoptions, $question->questiontext);
456
457
        }
        if (!empty($question->generalfeedback) && !empty($form->generalfeedback['itemid'])) {
458
459
460
461
            $question->generalfeedback = file_save_draft_area_files(
                    $form->generalfeedback['itemid'], $context->id,
                    'question', 'generalfeedback', (int)$question->id,
                    $this->fileoptions, $question->generalfeedback);
462
463
464
        }
        $DB->update_record('question', $question);

465
        // Now to save all the answers and type-specific options.
466
467
        $form->id = $question->id;
        $form->qtype = $question->qtype;
468
        $form->category = $question->category;
469
        $form->questiontext = $question->questiontext;
470
        $form->questiontextformat = $question->questiontextformat;
471
        // Current context.
472
        $form->context = $context;
473
474
475
476

        $result = $this->save_question_options($form);

        if (!empty($result->error)) {
477
            print_error($result->error);
478
479
480
        }

        if (!empty($result->notice)) {
481
            notice($result->notice, "question.php?id={$question->id}");
482
483
484
        }

        if (!empty($result->noticeyesno)) {
485
486
            throw new coding_exception(
                    '$result->noticeyesno no longer supported in save_question.');
487
488
        }

489
        // Give the question a unique version stamp determined by question_hash().
490
491
        $DB->set_field('question', 'version', question_hash($question),
                array('id' => $question->id));
492

493
494
495
496
497
498
499
500
501
502
        if ($newquestion) {
            // Log the creation of this question.
            $event = \core\event\question_created::create_from_question_instance($question, $context);
            $event->trigger();
        } else {
            // Log the update of this question.
            $event = \core\event\question_updated::create_from_question_instance($question, $context);
            $event->trigger();
        }

503
        $transaction->allow_commit();
504

505
506
        return $question;
    }
507

508
    /**
509
510
511
     * Saves question-type specific options
     *
     * This is called by {@link save_question()} to save the question-type specific data
512
     * @return object $result->error or $result->notice
513
514
515
516
     * @param object $question  This holds the information from the editing form,
     *      it is not a standard question object.
     */
    public function save_question_options($question) {
517
        global $DB;
518
        $extraquestionfields = $this->extra_question_fields();
519

520
521
        if (is_array($extraquestionfields)) {
            $question_extension_table = array_shift($extraquestionfields);
522

523
            $function = 'update_record';
524
            $questionidcolname = $this->questionid_column_name();
525
526
            $options = $DB->get_record($question_extension_table,
                    array($questionidcolname => $question->id));
527
528
            if (!$options) {
                $function = 'insert_record';
529
                $options = new stdClass();
530
                $options->$questionidcolname = $question->id;
531
            }
532
            foreach ($extraquestionfields as $field) {
533
                if (property_exists($question, $field)) {
534
                    $options->$field = $question->$field;
535
536
                }
            }
537

538
            $DB->{$function}($question_extension_table, $options);
539
        }
540
    }
541

542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
    /**
     * Save the answers, with any extra data.
     *
     * Questions that use answers will call it from {@link save_question_options()}.
     * @param object $question  This holds the information from the editing form,
     *      it is not a standard question object.
     * @return object $result->error or $result->notice
     */
    public function save_question_answers($question) {
        global $DB;

        $context = $question->context;
        $oldanswers = $DB->get_records('question_answers',
                array('question' => $question->id), 'id ASC');

        // We need separate arrays for answers and extra answer data, so no JOINS there.
558
        $extraanswerfields = $this->extra_answer_fields();
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
        $isextraanswerfields = is_array($extraanswerfields);
        $extraanswertable = '';
        $oldanswerextras = array();
        if ($isextraanswerfields) {
            $extraanswertable = array_shift($extraanswerfields);
            if (!empty($oldanswers)) {
                $oldanswerextras = $DB->get_records_sql("SELECT * FROM {{$extraanswertable}} WHERE " .
                    'answerid IN (SELECT id FROM {question_answers} WHERE question = ' . $question->id . ')' );
            }
        }

        // Insert all the new answers.
        foreach ($question->answer as $key => $answerdata) {
            // Check for, and ignore, completely blank answer from the form.
            if ($this->is_answer_empty($question, $key)) {
                continue;
            }

            // Update an existing answer if possible.
            $answer = array_shift($oldanswers);
            if (!$answer) {
                $answer = new stdClass();
                $answer->question = $question->id;
                $answer->answer = '';
                $answer->feedback = '';
                $answer->id = $DB->insert_record('question_answers', $answer);
            }

            $answer = $this->fill_answer_fields($answer, $question, $key, $context);
            $DB->update_record('question_answers', $answer);

            if ($isextraanswerfields) {
                // Check, if this answer contains some extra field data.
                if ($this->is_extra_answer_fields_empty($question, $key)) {
                    continue;
                }

                $answerextra = array_shift($oldanswerextras);
                if (!$answerextra) {
                    $answerextra = new stdClass();
                    $answerextra->answerid = $answer->id;
                    // Avoid looking for correct default for any possible DB field type
                    // by setting real values.
                    $answerextra = $this->fill_extra_answer_fields($answerextra, $question, $key, $context, $extraanswerfields);
                    $answerextra->id = $DB->insert_record($extraanswertable, $answerextra);
                } else {
                    // Update answerid, as record may be reused from another answer.
                    $answerextra->answerid = $answer->id;
                    $answerextra = $this->fill_extra_answer_fields($answerextra, $question, $key, $context, $extraanswerfields);
                    $DB->update_record($extraanswertable, $answerextra);
                }
            }
        }

        if ($isextraanswerfields) {
            // Delete any left over extra answer fields records.
            $oldanswerextraids = array();
            foreach ($oldanswerextras as $oldextra) {
                $oldanswerextraids[] = $oldextra->id;
            }
            $DB->delete_records_list($extraanswertable, 'id', $oldanswerextraids);
        }

        // Delete any left over old answer records.
        $fs = get_file_storage();
        foreach ($oldanswers as $oldanswer) {
            $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
            $DB->delete_records('question_answers', array('id' => $oldanswer->id));
        }
    }

    /**
     * Returns true is answer with the $key is empty in the question data and should not be saved in DB.
     *
     * The questions using question_answers table may want to overload this. Default code will work
     * for shortanswer and similar question types.
     * @param object $questiondata This holds the information from the question editing form or import.
     * @param int $key A key of the answer in question.
     * @return bool True if answer shouldn't be saved in DB.
     */
    protected function is_answer_empty($questiondata, $key) {
        return trim($questiondata->answer[$key]) == '' && $questiondata->fraction[$key] == 0 &&
                    html_is_blank($questiondata->feedback[$key]['text']);
    }

    /**
     * Return $answer, filling necessary fields for the question_answers table.
     *
     * The questions using question_answers table may want to overload this. Default code will work
     * for shortanswer and similar question types.
     * @param stdClass $answer Object to save data.
     * @param object $questiondata This holds the information from the question editing form or import.
     * @param int $key A key of the answer in question.
     * @param object $context needed for working with files.
     * @return $answer answer with filled data.
     */
    protected function fill_answer_fields($answer, $questiondata, $key, $context) {
        $answer->answer   = $questiondata->answer[$key];
        $answer->fraction = $questiondata->fraction[$key];
        $answer->feedback = $this->import_or_save_files($questiondata->feedback[$key],
                $context, 'question', 'answerfeedback', $answer->id);
        $answer->feedbackformat = $questiondata->feedback[$key]['format'];
        return $answer;
    }

    /**
Eloy Lafuente's avatar
Eloy Lafuente committed
665
     * Returns true if extra answer fields for answer with the $key is empty
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
     * in the question data and should not be saved in DB.
     *
     * Questions where extra answer fields are optional will want to overload this.
     * @param object $questiondata This holds the information from the question editing form or import.
     * @param int $key A key of the answer in question.
     * @return bool True if extra answer data shouldn't be saved in DB.
     */
    protected function is_extra_answer_fields_empty($questiondata, $key) {
        // No extra answer data in base class.
        return true;
    }

    /**
     * Return $answerextra, filling necessary fields for the extra answer fields table.
     *
     * The questions may want to overload it to save files or do other data processing.
     * @param stdClass $answerextra Object to save data.
     * @param object $questiondata This holds the information from the question editing form or import.
     * @param int $key A key of the answer in question.
     * @param object $context needed for working with files.
     * @param array $extraanswerfields extra answer fields (without table name).
     * @return $answer answerextra with filled data.
     */
    protected function fill_extra_answer_fields($answerextra, $questiondata, $key, $context, $extraanswerfields) {
        foreach ($extraanswerfields as $field) {
            // The $questiondata->$field[$key] won't work in PHP, break it down to two strings of code.
            $fieldarray = $questiondata->$field;
            $answerextra->$field = $fieldarray[$key];
        }
        return $answerextra;
696
697
    }

698
    public function save_hints($formdata, $withparts = false) {
699
        global $DB;
700
701
        $context = $formdata->context;

702
703
        $oldhints = $DB->get_records('question_hints',
                array('questionid' => $formdata->id), 'id ASC');
704
705


706
        $numhints = $this->count_hints_on_form($formdata, $withparts);
707
708

        for ($i = 0; $i < $numhints; $i += 1) {
709
710
            if (html_is_blank($formdata->hint[$i]['text'])) {
                $formdata->hint[$i]['text'] = '';
711
712
713
            }

            if ($withparts) {
714
715
                $clearwrong = !empty($formdata->hintclearwrong[$i]);
                $shownumcorrect = !empty($formdata->hintshownumcorrect[$i]);
716
717
            }

718
            if ($this->is_hint_empty_in_form_data($formdata, $i, $withparts)) {
719
720
721
                continue;
            }

722
            // Update an existing hint if possible.
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
            $hint = array_shift($oldhints);
            if (!$hint) {
                $hint = new stdClass();
                $hint->questionid = $formdata->id;
                $hint->hint = '';
                $hint->id = $DB->insert_record('question_hints', $hint);
            }

            $hint->hint = $this->import_or_save_files($formdata->hint[$i],
                    $context, 'question', 'hint', $hint->id);
            $hint->hintformat = $formdata->hint[$i]['format'];
            if ($withparts) {
                $hint->clearwrong = $clearwrong;
                $hint->shownumcorrect = $shownumcorrect;
            }
738
            $hint->options = $this->save_hint_options($formdata, $i, $withparts);
739
740
741
742
743
            $DB->update_record('question_hints', $hint);
        }

        // Delete any remaining old hints.
        $fs = get_file_storage();
744
        foreach ($oldhints as $oldhint) {
745
746
            $fs->delete_area_files($context->id, 'question', 'hint', $oldhint->id);
            $DB->delete_records('question_hints', array('id' => $oldhint->id));
747
748
749
        }
    }

750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
    /**
     * Count number of hints on the form.
     * Overload if you use custom hint controls.
     * @param object $formdata the data from the form.
     * @param bool $withparts whether to take into account clearwrong and shownumcorrect options.
     * @return int count of hints on the form.
     */
    protected function count_hints_on_form($formdata, $withparts) {
        if (!empty($formdata->hint)) {
            $numhints = max(array_keys($formdata->hint)) + 1;
        } else {
            $numhints = 0;
        }

        if ($withparts) {
            if (!empty($formdata->hintclearwrong)) {
                $numclears = max(array_keys($formdata->hintclearwrong)) + 1;
            } else {
                $numclears = 0;
            }
            if (!empty($formdata->hintshownumcorrect)) {
                $numshows = max(array_keys($formdata->hintshownumcorrect)) + 1;
            } else {
                $numshows = 0;
            }
            $numhints = max($numhints, $numclears, $numshows);
        }
        return $numhints;
    }

    /**
     * Determine if the hint with specified number is not empty and should be saved.
     * Overload if you use custom hint controls.
     * @param object $formdata the data from the form.
     * @param int $number number of hint under question.
     * @param bool $withparts whether to take into account clearwrong and shownumcorrect options.
     * @return bool is this particular hint data empty.
     */
    protected function is_hint_empty_in_form_data($formdata, $number, $withparts) {
        if ($withparts) {
            return empty($formdata->hint[$number]['text']) && empty($formdata->hintclearwrong[$number]) &&
                    empty($formdata->hintshownumcorrect[$number]);
        } else {
            return  empty($formdata->hint[$number]['text']);
        }
    }

    /**
     * Save additional question type data into the hint optional field.
     * Overload if you use custom hint information.
     * @param object $formdata the data from the form.
     * @param int $number number of hint to get options from.
     * @param bool $withparts whether question have parts.
     * @return string value to save into the options field of question_hints table.
     */
    protected function save_hint_options($formdata, $number, $withparts) {
        return null;    // By default, options field is unused.
    }

809
810
811
812
813
814
    /**
     * Can be used to {@link save_question_options()} to transfer the combined
     * feedback fields from $formdata to $options.
     * @param object $options the $question->options object being built.
     * @param object $formdata the data from the form.
     * @param object $context the context the quetsion is being saved into.
815
     * @param bool $withparts whether $options->shownumcorrect should be set.
816
     */
817
818
    protected function save_combined_feedback_helper($options, $formdata,
            $context, $withparts = false) {
819
820
821
        $options->correctfeedback = $this->import_or_save_files($formdata->correctfeedback,
                $context, 'question', 'correctfeedback', $formdata->id);
        $options->correctfeedbackformat = $formdata->correctfeedback['format'];
822
823
824

        $options->partiallycorrectfeedback = $this->import_or_save_files(
                $formdata->partiallycorrectfeedback,
825
826
                $context, 'question', 'partiallycorrectfeedback', $formdata->id);
        $options->partiallycorrectfeedbackformat = $formdata->partiallycorrectfeedback['format'];
827

828
829
830
831
832
833
834
835
836
837
838
        $options->incorrectfeedback = $this->import_or_save_files($formdata->incorrectfeedback,
                $context, 'question', 'incorrectfeedback', $formdata->id);
        $options->incorrectfeedbackformat = $formdata->incorrectfeedback['format'];

        if ($withparts) {
            $options->shownumcorrect = !empty($formdata->shownumcorrect);
        }

        return $options;
    }

839
840
841
842
843
844
845
846
847
848
849
850
851
    /**
     * Loads the question type specific options for the question.
     *
     * This function loads any question type specific options for the
     * question from the database into the question object. This information
     * is placed in the $question->options field. A question type is
     * free, however, to decide on a internal structure of the options field.
     * @return bool            Indicates success or failure.
     * @param object $question The question object for the question. This object
     *                         should be updated to include the question type
     *                         specific information (it is passed by reference).
     */
    public function get_question_options($question) {
852
853
854
855
856
        global $CFG, $DB, $OUTPUT;

        if (!isset($question->options)) {
            $question->options = new stdClass();
        }
857

858
859
860
861
862
863
        $extraquestionfields = $this->extra_question_fields();
        if (is_array($extraquestionfields)) {
            $question_extension_table = array_shift($extraquestionfields);
            $extra_data = $DB->get_record($question_extension_table,
                    array($this->questionid_column_name() => $question->id),
                    implode(', ', $extraquestionfields));
864
            if ($extra_data) {
865
                foreach ($extraquestionfields as $field) {
866
867
868
                    $question->options->$field = $extra_data->$field;
                }
            } else {
869
870
                echo $OUTPUT->notification('Failed to load question options from the table ' .
                        $question_extension_table . ' for questionid ' . $question->id);
871
872
873
874
                return false;
            }
        }

875
876
        $extraanswerfields = $this->extra_answer_fields();
        if (is_array($extraanswerfields)) {
877
878
            $answerextensiontable = array_shift($extraanswerfields);
            // Use LEFT JOIN in case not every answer has extra data.
879
            $question->options->answers = $DB->get_records_sql("
880
881
882
883
                    SELECT qa.*, qax." . implode(', qax.', $extraanswerfields) . '
                    FROM {question_answers} qa ' . "
                    LEFT JOIN {{$answerextensiontable}} qax ON qa.id = qax.answerid
                    WHERE qa.question = ?
884
                    ORDER BY qa.id", array($question->id));
885
            if (!$question->options->answers) {
886
                echo $OUTPUT->notification('Failed to load question answers from the table ' .
887
                        $answerextensiontable . 'for questionid ' . $question->id);
888
889
890
                return false;
            }
        } else {
891
892
893
894
            // Don't check for success or failure because some question types do
            // not use the answers table.
            $question->options->answers = $DB->get_records('question_answers',
                    array('question' => $question->id), 'id ASC');
895
896
        }

897
898
        $question->hints = $DB->get_records('question_hints',
                array('questionid' => $question->id), 'id ASC');
899

900
901
902
903
        return true;
    }

    /**
904
905
906
907
908
909
910
911
912
913
     * Create an appropriate question_definition for the question of this type
     * using data loaded from the database.
     * @param object $questiondata the question data loaded from the database.
     * @return question_definition the corresponding question_definition.
     */
    public function make_question($questiondata) {
        $question = $this->make_question_instance($questiondata);
        $this->initialise_question_instance($question, $questiondata);
        return $question;
    }
gustav_delius's avatar
gustav_delius committed
914

915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
    /**
     * Create an appropriate question_definition for the question of this type
     * using data loaded from the database.
     * @param object $questiondata the question data loaded from the database.
     * @return question_definition an instance of the appropriate question_definition subclass.
     *      Still needs to be initialised.
     */
    protected function make_question_instance($questiondata) {
        question_bank::load_question_definition_classes($this->name());
        $class = 'qtype_' . $this->name() . '_question';
        return new $class();
    }

    /**
     * Initialise the common question_definition fields.
     * @param question_definition $question the question_definition we are creating.
     * @param object $questiondata the question data loaded from the database.
     */
    protected function initialise_question_instance(question_definition $question, $questiondata) {
        $question->id = $questiondata->id;
        $question->category = $questiondata->category;
936
        $question->contextid = $questiondata->contextid;
937
938
939
940
941
942
        $question->parent = $questiondata->parent;
        $question->qtype = $this;
        $question->name = $questiondata->name;
        $question->questiontext = $questiondata->questiontext;
        $question->questiontextformat = $questiondata->questiontextformat;
        $question->generalfeedback = $questiondata->generalfeedback;
943
        $question->generalfeedbackformat = $questiondata->generalfeedbackformat;
944
945
946
947
948
949
        $question->defaultmark = $questiondata->defaultmark + 0;
        $question->length = $questiondata->length;
        $question->penalty = $questiondata->penalty;
        $question->stamp = $questiondata->stamp;
        $question->version = $questiondata->version;
        $question->hidden = $questiondata->hidden;
950
        $question->idnumber = $questiondata->idnumber;
951
952
953
954
955
        $question->timecreated = $questiondata->timecreated;
        $question->timemodified = $questiondata->timemodified;
        $question->createdby = $questiondata->createdby;
        $question->modifiedby = $questiondata->modifiedby;

956
        // Fill extra question fields values.
957
958
        $extraquestionfields = $this->extra_question_fields();
        if (is_array($extraquestionfields)) {
959
            // Omit table name.
960
            array_shift($extraquestionfields);
961
            foreach ($extraquestionfields as $field) {
962
963
964
965
                $question->$field = $questiondata->options->$field;
            }
        }

966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
        $this->initialise_question_hints($question, $questiondata);
    }

    /**
     * Initialise question_definition::hints field.
     * @param question_definition $question the question_definition we are creating.
     * @param object $questiondata the question data loaded from the database.
     */
    protected function initialise_question_hints(question_definition $question, $questiondata) {
        if (empty($questiondata->hints)) {
            return;
        }
        foreach ($questiondata->hints as $hint) {
            $question->hints[] = $this->make_hint($hint);
        }
gustav_delius's avatar
gustav_delius committed
981
982
983
    }

    /**
984
985
986
987
     * Create a question_hint, or an appropriate subclass for this question,
     * from a row loaded from the database.
     * @param object $hint the DB row from the question hints table.
     * @return question_hint
988
     */
989
990
991
992
    protected function make_hint($hint) {
        return question_hint::load_from_record($hint);
    }

993
994
995
996
    /**
     * Initialise the combined feedback fields.
     * @param question_definition $question the question_definition we are creating.
     * @param object $questiondata the question data loaded from the database.
997
     * @param bool $withparts whether to set the shownumcorrect field.
998
     */
999
1000
    protected function initialise_combined_feedback(question_definition $question,
            $questiondata, $withparts = false) {
1001
1002
1003
        $question->correctfeedback = $questiondata->options->correctfeedback;
        $question->correctfeedbackformat = $questiondata->options->correctfeedbackformat;
        $question->partiallycorrectfeedback = $questiondata->options->partiallycorrectfeedback;
1004
1005
        $question->partiallycorrectfeedbackformat =
                $questiondata->options->partiallycorrectfeedbackformat;
1006
1007
1008
1009
1010
1011
1012
        $question->incorrectfeedback = $questiondata->options->incorrectfeedback;
        $question->incorrectfeedbackformat = $questiondata->options->incorrectfeedbackformat;
        if ($withparts) {
            $question->shownumcorrect = $questiondata->options->shownumcorrect;
        }
    }

1013
1014
1015
1016
    /**
     * Initialise question_definition::answers field.
     * @param question_definition $question the question_definition we are creating.
     * @param object $questiondata the question data loaded from the database.
1017
1018
1019
1020
1021
1022
1023
1024
     * @param bool $forceplaintextanswers most qtypes assume that answers are
     *      FORMAT_PLAIN, and dont use the answerformat DB column (it contains
     *      the default 0 = FORMAT_MOODLE). Therefore, by default this method
     *      ingores answerformat. Pass false here to use answerformat. For example
     *      multichoice does this.
     */
    protected function initialise_question_answers(question_definition $question,
            $questiondata, $forceplaintextanswers = true) {
1025
1026
1027
1028
1029
        $question->answers = array();
        if (empty($questiondata->options->answers)) {
            return;
        }
        foreach ($questiondata->options->answers as $a) {
1030
            $question->answers[$a->id] = $this->make_answer($a);
1031
1032
1033
            if (!$forceplaintextanswers) {
                $question->answers[$a->id]->answerformat = $a->answerformat;
            }
1034
1035
        }
    }
1036

1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
    /**
     * Create a question_answer, or an appropriate subclass for this question,
     * from a row loaded from the database.
     * @param object $answer the DB row from the question_answers table plus extra answer fields.
     * @return question_answer
     */
    protected function make_answer($answer) {
        return new question_answer($answer->id, $answer->answer,
                    $answer->fraction, $answer->feedback, $answer->feedbackformat);
    }

1048
    /**
1049
     * Deletes the question-type specific data when a question is deleted.
1050
1051
     * @param int $question the question being deleted.
     * @param int $contextid the context this quesiotn belongs to.
1052
1053
1054
1055
1056
     */
    public function delete_question($questionid, $contextid) {
        global $DB;

        $this->delete_files($questionid, $contextid);
1057

1058
1059
1060
        $extraquestionfields = $this->extra_question_fields();
        if (is_array($extraquestionfields)) {
            $question_extension_table = array_shift($extraquestionfields);
1061
1062
            $DB->delete_records($question_extension_table,
                    array($this->questionid_column_name() => $questionid));
1063
1064
        }

1065
1066
1067
        $extraanswerfields = $this->extra_answer_fields();
        if (is_array($extraanswerfields)) {
            $answer_extension_table = array_shift($extraanswerfields);
1068
            $DB->delete_records_select($answer_extension_table,
1069
1070
                    'answerid IN (SELECT qa.id FROM {question_answers} qa WHERE qa.question = ?)',
                    array($questionid));
1071
1072
        }

1073
        $DB->delete_records('question_answers', array('question' => $questionid));
1074

1075
        $DB->delete_records('question_hints', array('questionid' => $questionid));
1076
1077
1078
    }

    /**
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
     * Returns the number of question numbers which are used by the question
     *
     * This function returns the number of question numbers to be assigned
     * to the question. Most question types will have length one; they will be
     * assigned one number. The 'description' type, however does not use up a
     * number and so has a length of zero. Other question types may wish to
     * handle a bundle of questions and hence return a number greater than one.
     * @return int         The number of question numbers which should be
     *                         assigned to the question.
     * @param object $question The question whose length is to be determined.
     *                         Question type specific information is included.
     */
1091
    public function actual_number_of_questions($question) {
1092
        // By default, each question is given one number.
1093
1094
1095
        return 1;
    }

1096
    /**
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
     * Calculate the score a monkey would get on a question by clicking randomly.
     *
     * Some question types have significant non-zero average expected score
     * of the response is just selected randomly. For example 50% for a
     * true-false question. It is useful to know what this is. For example
     * it gets shown in the quiz statistics report.
     *
     * For almost any open-ended question type (E.g. shortanswer or numerical)
     * this should be 0.
     *
     * For selective response question types (e.g. multiple choice), you can probably compute this.
     *
     * For particularly complicated question types the may be impossible or very
     * difficult to compute. In this case return null. (Or, if the expected score
     * is very tiny even though the exact value is unknown, it may appropriate
     * to return 0.)
     *
     * @param stdClass $questiondata data defining a question, as returned by
     *      question_bank::load_question_data().
1116
     * @return number|null either a fraction estimating what the student would
1117
     *      score by guessing, or null, if it is not possible to estimate.
1118
     */
1119
    public function get_random_guess_score($questiondata) {
1120
1121
        return 0;
    }
1122

1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
    /**
     * Whether or not to break down question stats and response analysis, for a question defined by $questiondata.
     *
     * @param object $questiondata The full question definition data.
     * @return bool
     */
    public function break_down_stats_and_response_analysis_by_variant($questiondata) {
        return true;
    }

1133
    /**
1134
     * This method should return all the possible types of response that are
1135
     * recognised for this question.
1136
     *
1137
1138
1139
     * The question is modelled as comprising one or more subparts. For each
     * subpart, there are one or more classes that that students response
     * might fall into, each of those classes earning a certain score.
1140
     *
1141
1142
1143
     * For example, in a shortanswer question, there is only one subpart, the
     * text entry field. The response the student gave will be classified according
     * to which of the possible $question->options->answers it matches.
1144
     *
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
     * For the matching question type, there will be one subpart for each
     * question stem, and for each stem, each of the possible choices is a class
     * of student's response.
     *
     * A response is an object with two fields, ->responseclass is a string
     * presentation of that response, and ->fraction, the credit for a response
     * in that class.
     *
     * Array keys have no specific meaning, but must be unique, and must be
     * the same if this function is called repeatedly.
     *
     * @param object $question the question definition data.
     * @return array keys are subquestionid, values are arrays of possible
     *      responses to that subquestion.
1159
     */
1160
    public function get_possible_responses($questiondata) {
1161
        return array();
1162
    }
1163

1164
    /**
1165
1166
1167
     * Utility method used by {@link qtype_renderer::head_code()}. It looks
     * for any of the files script.js or script.php that exist in the plugin
     * folder and ensures they get included.
1168
     */
1169
    public function find_standard_scripts() {
1170
1171
        global $PAGE;

1172
        $plugindir = $this->plugin_dir();
1173
        $plugindirrel = 'question/type/' . $this->name();
1174
1175

        if (file_exists($plugindir . '/script.js')) {
1176
            $PAGE->requires->js('/' . $plugindirrel . '/script.js');
1177
1178
        }
        if (file_exists($plugindir . '/script.php')) {
1179
            $PAGE->requires->js('/' . $plugindirrel . '/script.php');
1180
        }
1181
1182
    }

1183
    /**
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
     * Returns true if the editing wizard is finished, false otherwise.
     *
     * The default implementation returns true, which is suitable for all question-
     * types that only use one editing form. This function is used in
     * question.php to decide whether we can regrade any states of the edited
     * question and redirect to edit.php.
     *
     * The dataset dependent question-type, which is extended by the calculated
     * question-type, overwrites this method because it uses multiple pages (i.e.
     * a wizard) to set up the question and associated datasets.
     *
     * @param object $form  The data submitted by the previous page.
     *
     * @return bool      Whether the wizard's last page was submitted or not.
     */
1199
    public function finished_edit_wizard($form) {
1200
        // In the default case there is only one edit page.
1201
        return true;
1202
1203
    }

1204
    // IMPORT/EXPORT FUNCTIONS --------------------------------- .