forked from moodle/moodle
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathquestionlib.php
2250 lines (1976 loc) · 82.2 KB
/
questionlib.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
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
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
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?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/>.
/**
* Code for handling and processing questions
*
* This is code that is module independent, i.e., can be used by any module that
* uses questions, like quiz, lesson, ..
* This script also loads the questiontype classes
* Code for handling the editing of questions is in {@link question/editlib.php}
*
* TODO: separate those functions which form part of the API
* from the helper functions.
*
* @package moodlecore
* @subpackage questionbank
* @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/question/engine/lib.php');
require_once($CFG->dirroot . '/question/type/questiontypebase.php');
/// CONSTANTS ///////////////////////////////////
/**
* Constant determines the number of answer boxes supplied in the editing
* form for multiple choice and similar question types.
*/
define("QUESTION_NUMANS", 10);
/**
* Constant determines the number of answer boxes supplied in the editing
* form for multiple choice and similar question types to start with, with
* the option of adding QUESTION_NUMANS_ADD more answers.
*/
define("QUESTION_NUMANS_START", 3);
/**
* Constant determines the number of answer boxes to add in the editing
* form for multiple choice and similar question types when the user presses
* 'add form fields button'.
*/
define("QUESTION_NUMANS_ADD", 3);
/**
* Move one question type in a list of question types. If you try to move one element
* off of the end, nothing will change.
*
* @param array $sortedqtypes An array $qtype => anything.
* @param string $tomove one of the keys from $sortedqtypes
* @param integer $direction +1 or -1
* @return array an array $index => $qtype, with $index from 0 to n in order, and
* the $qtypes in the same order as $sortedqtypes, except that $tomove will
* have been moved one place.
*/
function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
$neworder = array_keys($sortedqtypes);
// Find the element to move.
$key = array_search($tomove, $neworder);
if ($key === false) {
return $neworder;
}
// Work out the other index.
$otherkey = $key + $direction;
if (!isset($neworder[$otherkey])) {
return $neworder;
}
// Do the swap.
$swap = $neworder[$otherkey];
$neworder[$otherkey] = $neworder[$key];
$neworder[$key] = $swap;
return $neworder;
}
/**
* Save a new question type order to the config_plugins table.
* @global object
* @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
* @param $config get_config('question'), if you happen to have it around, to save one DB query.
*/
function question_save_qtype_order($neworder, $config = null) {
global $DB;
if (is_null($config)) {
$config = get_config('question');
}
foreach ($neworder as $index => $qtype) {
$sortvar = $qtype . '_sortorder';
if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
set_config($sortvar, $index + 1, 'question');
}
}
}
/// FUNCTIONS //////////////////////////////////////////////////////
/**
* @param array $questionids of question ids.
* @return boolean whether any of these questions are being used by any part of Moodle.
*/
function questions_in_use($questionids) {
global $CFG;
if (question_engine::questions_in_use($questionids)) {
return true;
}
foreach (core_component::get_plugin_list('mod') as $module => $path) {
$lib = $path . '/lib.php';
if (is_readable($lib)) {
include_once($lib);
$fn = $module . '_questions_in_use';
if (function_exists($fn)) {
if ($fn($questionids)) {
return true;
}
} else {
// Fallback for legacy modules.
$fn = $module . '_question_list_instances';
if (function_exists($fn)) {
foreach ($questionids as $questionid) {
$instances = $fn($questionid);
if (!empty($instances)) {
return true;
}
}
}
}
}
}
return false;
}
/**
* Determine whether there arey any questions belonging to this context, that is whether any of its
* question categories contain any questions. This will return true even if all the questions are
* hidden.
*
* @param mixed $context either a context object, or a context id.
* @return boolean whether any of the question categories beloning to this context have
* any questions in them.
*/
function question_context_has_any_questions($context) {
global $DB;
if (is_object($context)) {
$contextid = $context->id;
} else if (is_numeric($context)) {
$contextid = $context;
} else {
print_error('invalidcontextinhasanyquestions', 'question');
}
return $DB->record_exists_sql("SELECT *
FROM {question} q
JOIN {question_categories} qc ON qc.id = q.category
WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
}
/**
* Check whether a given grade is one of a list of allowed options. If not,
* depending on $matchgrades, either return the nearest match, or return false
* to signal an error.
* @param array $gradeoptionsfull list of valid options
* @param int $grade grade to be tested
* @param string $matchgrades 'error' or 'nearest'
* @return mixed either 'fixed' value or false if error.
*/
function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error') {
if ($matchgrades == 'error') {
// (Almost) exact match, or an error.
foreach ($gradeoptionsfull as $value => $option) {
// Slightly fuzzy test, never check floats for equality.
if (abs($grade - $value) < 0.00001) {
return $value; // Be sure the return the proper value.
}
}
// Didn't find a match so that's an error.
return false;
} else if ($matchgrades == 'nearest') {
// Work out nearest value
$best = false;
$bestmismatch = 2;
foreach ($gradeoptionsfull as $value => $option) {
$newmismatch = abs($grade - $value);
if ($newmismatch < $bestmismatch) {
$best = $value;
$bestmismatch = $newmismatch;
}
}
return $best;
} else {
// Unknow option passed.
throw new coding_exception('Unknown $matchgrades ' . $matchgrades .
' passed to match_grade_options');
}
}
/**
* Remove stale questions from a category.
*
* While questions should not be left behind when they are not used any more,
* it does happen, maybe via restore, or old logic, or uncovered scenarios. When
* this happens, the users are unable to delete the question category unless
* they move those stale questions to another one category, but to them the
* category is empty as it does not contain anything. The purpose of this function
* is to detect the questions that may have gone stale and remove them.
*
* You will typically use this prior to checking if the category contains questions.
*
* The stale questions (unused and hidden to the user) handled are:
* - hidden questions
* - random questions
*
* @param int $categoryid The category ID.
*/
function question_remove_stale_questions_from_category($categoryid) {
global $DB;
$select = 'category = :categoryid AND (qtype = :qtype OR hidden = :hidden)';
$params = ['categoryid' => $categoryid, 'qtype' => 'random', 'hidden' => 1];
$questions = $DB->get_recordset_select("question", $select, $params, '', 'id');
foreach ($questions as $question) {
// The function question_delete_question does not delete questions in use.
question_delete_question($question->id);
}
$questions->close();
}
/**
* Category is about to be deleted,
* 1/ All questions are deleted for this question category.
* 2/ Any questions that can't be deleted are moved to a new category
* NOTE: this function is called from lib/db/upgrade.php
*
* @param object|coursecat $category course category object
*/
function question_category_delete_safe($category) {
global $DB;
$criteria = array('category' => $category->id);
$context = context::instance_by_id($category->contextid, IGNORE_MISSING);
$rescue = null; // See the code around the call to question_save_from_deletion.
// Deal with any questions in the category.
if ($questions = $DB->get_records('question', $criteria, '', 'id,qtype')) {
// Try to delete each question.
foreach ($questions as $question) {
question_delete_question($question->id);
}
// Check to see if there were any questions that were kept because
// they are still in use somehow, even though quizzes in courses
// in this category will already have been deleted. This could
// happen, for example, if questions are added to a course,
// and then that course is moved to another category (MDL-14802).
$questionids = $DB->get_records_menu('question', $criteria, '', 'id, 1');
if (!empty($questionids)) {
$parentcontextid = SYSCONTEXTID;
$name = get_string('unknown', 'question');
if ($context !== false) {
$name = $context->get_context_name();
$parentcontext = $context->get_parent_context();
if ($parentcontext) {
$parentcontextid = $parentcontext->id;
}
}
question_save_from_deletion(array_keys($questionids), $parentcontextid, $name, $rescue);
}
}
// Now delete the category.
$DB->delete_records('question_categories', array('id' => $category->id));
}
/**
* Tests whether any question in a category is used by any part of Moodle.
*
* @param integer $categoryid a question category id.
* @param boolean $recursive whether to check child categories too.
* @return boolean whether any question in this category is in use.
*/
function question_category_in_use($categoryid, $recursive = false) {
global $DB;
//Look at each question in the category
if ($questions = $DB->get_records_menu('question',
array('category' => $categoryid), '', 'id, 1')) {
if (questions_in_use(array_keys($questions))) {
return true;
}
}
if (!$recursive) {
return false;
}
//Look under child categories recursively
if ($children = $DB->get_records('question_categories',
array('parent' => $categoryid), '', 'id, 1')) {
foreach ($children as $child) {
if (question_category_in_use($child->id, $recursive)) {
return true;
}
}
}
return false;
}
/**
* Deletes question and all associated data from the database
*
* It will not delete a question if it is used by an activity module
* @param object $question The question being deleted
*/
function question_delete_question($questionid) {
global $DB;
$question = $DB->get_record_sql('
SELECT q.*, qc.contextid
FROM {question} q
JOIN {question_categories} qc ON qc.id = q.category
WHERE q.id = ?', array($questionid));
if (!$question) {
// In some situations, for example if this was a child of a
// Cloze question that was previously deleted, the question may already
// have gone. In this case, just do nothing.
return;
}
// Do not delete a question if it is used by an activity module
if (questions_in_use(array($questionid))) {
return;
}
$dm = new question_engine_data_mapper();
$dm->delete_previews($questionid);
// delete questiontype-specific data
question_bank::get_qtype($question->qtype, false)->delete_question(
$questionid, $question->contextid);
// Delete all tag instances.
core_tag_tag::remove_all_item_tags('core_question', 'question', $question->id);
// Now recursively delete all child questions
if ($children = $DB->get_records('question',
array('parent' => $questionid), '', 'id, qtype')) {
foreach ($children as $child) {
if ($child->id != $questionid) {
question_delete_question($child->id);
}
}
}
// Finally delete the question record itself
$DB->delete_records('question', array('id' => $questionid));
question_bank::notify_question_edited($questionid);
}
/**
* All question categories and their questions are deleted for this context id.
*
* @param object $contextid The contextid to delete question categories from
* @return array Feedback from deletes (if any)
*/
function question_delete_context($contextid) {
global $DB;
//To store feedback to be showed at the end of the process
$feedbackdata = array();
//Cache some strings
$strcatdeleted = get_string('unusedcategorydeleted', 'question');
$fields = 'id, parent, name, contextid';
if ($categories = $DB->get_records('question_categories', array('contextid' => $contextid), 'parent', $fields)) {
//Sort categories following their tree (parent-child) relationships
//this will make the feedback more readable
$categories = sort_categories_by_tree($categories);
foreach ($categories as $category) {
question_category_delete_safe($category);
//Fill feedback
$feedbackdata[] = array($category->name, $strcatdeleted);
}
}
return $feedbackdata;
}
/**
* All question categories and their questions are deleted for this course.
*
* @param stdClass $course an object representing the activity
* @param boolean $feedback to specify if the process must output a summary of its work
* @return boolean
*/
function question_delete_course($course, $feedback=true) {
$coursecontext = context_course::instance($course->id);
$feedbackdata = question_delete_context($coursecontext->id, $feedback);
// Inform about changes performed if feedback is enabled.
if ($feedback && $feedbackdata) {
$table = new html_table();
$table->head = array(get_string('category', 'question'), get_string('action'));
$table->data = $feedbackdata;
echo html_writer::table($table);
}
return true;
}
/**
* Category is about to be deleted,
* 1/ All question categories and their questions are deleted for this course category.
* 2/ All questions are moved to new category
*
* @param object|coursecat $category course category object
* @param object|coursecat $newcategory empty means everything deleted, otherwise id of
* category where content moved
* @param boolean $feedback to specify if the process must output a summary of its work
* @return boolean
*/
function question_delete_course_category($category, $newcategory, $feedback=true) {
global $DB, $OUTPUT;
$context = context_coursecat::instance($category->id);
if (empty($newcategory)) {
$feedbackdata = question_delete_context($context->id, $feedback);
// Output feedback if requested.
if ($feedback && $feedbackdata) {
$table = new html_table();
$table->head = array(get_string('questioncategory', 'question'), get_string('action'));
$table->data = $feedbackdata;
echo html_writer::table($table);
}
} else {
// Move question categories to the new context.
if (!$newcontext = context_coursecat::instance($newcategory->id)) {
return false;
}
// Only move question categories if there is any question category at all!
if ($topcategory = question_get_top_category($context->id)) {
$newtopcategory = question_get_top_category($newcontext->id, true);
question_move_category_to_context($topcategory->id, $context->id, $newcontext->id);
$DB->set_field('question_categories', 'parent', $newtopcategory->id, array('parent' => $topcategory->id));
// Now delete the top category.
$DB->delete_records('question_categories', array('id' => $topcategory->id));
}
if ($feedback) {
$a = new stdClass();
$a->oldplace = $context->get_context_name();
$a->newplace = $newcontext->get_context_name();
echo $OUTPUT->notification(
get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
}
}
return true;
}
/**
* Enter description here...
*
* @param array $questionids of question ids
* @param object $newcontextid the context to create the saved category in.
* @param string $oldplace a textual description of the think being deleted,
* e.g. from get_context_name
* @param object $newcategory
* @return mixed false on
*/
function question_save_from_deletion($questionids, $newcontextid, $oldplace,
$newcategory = null) {
global $DB;
// Make a category in the parent context to move the questions to.
if (is_null($newcategory)) {
$newcategory = new stdClass();
$newcategory->parent = 0;
$newcategory->contextid = $newcontextid;
$newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
$newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
$newcategory->sortorder = 999;
$newcategory->stamp = make_unique_id_code();
$newcategory->id = $DB->insert_record('question_categories', $newcategory);
}
// Move any remaining questions to the 'saved' category.
if (!question_move_questions_to_category($questionids, $newcategory->id)) {
return false;
}
return $newcategory;
}
/**
* All question categories and their questions are deleted for this activity.
*
* @param object $cm the course module object representing the activity
* @param boolean $feedback to specify if the process must output a summary of its work
* @return boolean
*/
function question_delete_activity($cm, $feedback=true) {
global $DB;
$modcontext = context_module::instance($cm->id);
$feedbackdata = question_delete_context($modcontext->id, $feedback);
// Inform about changes performed if feedback is enabled.
if ($feedback && $feedbackdata) {
$table = new html_table();
$table->head = array(get_string('category', 'question'), get_string('action'));
$table->data = $feedbackdata;
echo html_writer::table($table);
}
return true;
}
/**
* This function will handle moving all tag instances to a new context for a
* given list of questions.
*
* Questions can be tagged in up to two contexts:
* 1.) The context the question exists in.
* 2.) The course context (if the question context is a higher context.
* E.g. course category context or system context.
*
* This means a question that exists in a higher context (e.g. course cat or
* system context) may have multiple groups of tags in any number of child
* course contexts.
*
* Questions in the course category context can be move "down" a context level
* into one of their child course contexts or activity contexts which affects the
* availability of that question in other courses / activities.
*
* In this case it makes the questions no longer available in the other course or
* activity contexts so we need to make sure that the tag instances in those other
* contexts are removed.
*
* @param stdClass[] $questions The list of question being moved (must include
* the id and contextid)
* @param context $newcontext The Moodle context the questions are being moved to
*/
function question_move_question_tags_to_new_context(array $questions, context $newcontext) {
// If the questions are moving to a new course/activity context then we need to
// find any existing tag instances from any unavailable course contexts and
// delete them because they will no longer be applicable (we don't support
// tagging questions across courses).
$instancestodelete = [];
$instancesfornewcontext = [];
$newcontextparentids = $newcontext->get_parent_context_ids();
$questionids = array_map(function($question) {
return $question->id;
}, $questions);
$questionstagobjects = core_tag_tag::get_items_tags('core_question', 'question', $questionids);
foreach ($questions as $question) {
$tagobjects = $questionstagobjects[$question->id];
foreach ($tagobjects as $tagobject) {
$tagid = $tagobject->taginstanceid;
$tagcontextid = $tagobject->taginstancecontextid;
$istaginnewcontext = $tagcontextid == $newcontext->id;
$istaginquestioncontext = $tagcontextid == $question->contextid;
if ($istaginnewcontext) {
// This tag instance is already in the correct context so we can
// ignore it.
continue;
}
if ($istaginquestioncontext) {
// This tag instance is in the question context so it needs to be
// updated.
$instancesfornewcontext[] = $tagid;
continue;
}
// These tag instances are in neither the new context nor the
// question context so we need to determine what to do based on
// the context they are in and the new question context.
$tagcontext = context::instance_by_id($tagcontextid);
$tagcoursecontext = $tagcontext->get_course_context(false);
// The tag is in a course context if get_course_context() returns
// itself.
$istaginstancecontextcourse = !empty($tagcoursecontext)
&& $tagcontext->id == $tagcoursecontext->id;
if ($istaginstancecontextcourse) {
// If the tag instance is in a course context we need to add some
// special handling.
$tagcontextparentids = $tagcontext->get_parent_context_ids();
$isnewcontextaparent = in_array($newcontext->id, $tagcontextparentids);
$isnewcontextachild = in_array($tagcontext->id, $newcontextparentids);
if ($isnewcontextaparent) {
// If the tag instance is a course context tag and the new
// context is still a parent context to the tag context then
// we can leave this tag where it is.
continue;
} else if ($isnewcontextachild) {
// If the new context is a child context (e.g. activity) of this
// tag instance then we should move all of this tag instance
// down into the activity context along with the question.
$instancesfornewcontext[] = $tagid;
} else {
// If the tag is in a course context that is no longer a parent
// or child of the new context then this tag instance should be
// removed.
$instancestodelete[] = $tagid;
}
} else {
// This is a catch all for any tag instances not in the question
// context or a course context. These tag instances should be
// updated to the new context id. This will clean up old invalid
// data.
$instancesfornewcontext[] = $tagid;
}
}
}
if (!empty($instancestodelete)) {
// Delete any course context tags that may no longer be valid.
core_tag_tag::delete_instances_by_id($instancestodelete);
}
if (!empty($instancesfornewcontext)) {
// Update the tag instances to the new context id.
core_tag_tag::change_instances_context($instancesfornewcontext, $newcontext);
}
}
/**
* This function should be considered private to the question bank, it is called from
* question/editlib.php question/contextmoveq.php and a few similar places to to the
* work of acutally moving questions and associated data. However, callers of this
* function also have to do other work, which is why you should not call this method
* directly from outside the questionbank.
*
* @param array $questionids of question ids.
* @param integer $newcategoryid the id of the category to move to.
*/
function question_move_questions_to_category($questionids, $newcategoryid) {
global $DB;
$newcontextid = $DB->get_field('question_categories', 'contextid',
array('id' => $newcategoryid));
list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
$questions = $DB->get_records_sql("
SELECT q.id, q.qtype, qc.contextid
FROM {question} q
JOIN {question_categories} qc ON q.category = qc.id
WHERE q.id $questionidcondition", $params);
foreach ($questions as $question) {
if ($newcontextid != $question->contextid) {
question_bank::get_qtype($question->qtype)->move_files(
$question->id, $question->contextid, $newcontextid);
}
}
// Move the questions themselves.
$DB->set_field_select('question', 'category', $newcategoryid,
"id $questionidcondition", $params);
// Move any subquestions belonging to them.
$DB->set_field_select('question', 'category', $newcategoryid,
"parent $questionidcondition", $params);
$newcontext = context::instance_by_id($newcontextid);
question_move_question_tags_to_new_context($questions, $newcontext);
// TODO Deal with datasets.
// Purge these questions from the cache.
foreach ($questions as $question) {
question_bank::notify_question_edited($question->id);
}
return true;
}
/**
* This function helps move a question cateogry to a new context by moving all
* the files belonging to all the questions to the new context.
* Also moves subcategories.
* @param integer $categoryid the id of the category being moved.
* @param integer $oldcontextid the old context id.
* @param integer $newcontextid the new context id.
*/
function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid) {
global $DB;
$questions = [];
$questionids = $DB->get_records_menu('question',
array('category' => $categoryid), '', 'id,qtype');
foreach ($questionids as $questionid => $qtype) {
question_bank::get_qtype($qtype)->move_files(
$questionid, $oldcontextid, $newcontextid);
// Purge this question from the cache.
question_bank::notify_question_edited($questionid);
$questions[] = (object) [
'id' => $questionid,
'contextid' => $oldcontextid
];
}
$newcontext = context::instance_by_id($newcontextid);
question_move_question_tags_to_new_context($questions, $newcontext);
$subcatids = $DB->get_records_menu('question_categories',
array('parent' => $categoryid), '', 'id,1');
foreach ($subcatids as $subcatid => $notused) {
$DB->set_field('question_categories', 'contextid', $newcontextid,
array('id' => $subcatid));
question_move_category_to_context($subcatid, $oldcontextid, $newcontextid);
}
}
/**
* Generate the URL for starting a new preview of a given question with the given options.
* @param integer $questionid the question to preview.
* @param string $preferredbehaviour the behaviour to use for the preview.
* @param float $maxmark the maximum to mark the question out of.
* @param question_display_options $displayoptions the display options to use.
* @param int $variant the variant of the question to preview. If null, one will
* be picked randomly.
* @param object $context context to run the preview in (affects things like
* filter settings, theme, lang, etc.) Defaults to $PAGE->context.
* @return moodle_url the URL.
*/
function question_preview_url($questionid, $preferredbehaviour = null,
$maxmark = null, $displayoptions = null, $variant = null, $context = null) {
$params = array('id' => $questionid);
if (is_null($context)) {
global $PAGE;
$context = $PAGE->context;
}
if ($context->contextlevel == CONTEXT_MODULE) {
$params['cmid'] = $context->instanceid;
} else if ($context->contextlevel == CONTEXT_COURSE) {
$params['courseid'] = $context->instanceid;
}
if (!is_null($preferredbehaviour)) {
$params['behaviour'] = $preferredbehaviour;
}
if (!is_null($maxmark)) {
$params['maxmark'] = $maxmark;
}
if (!is_null($displayoptions)) {
$params['correctness'] = $displayoptions->correctness;
$params['marks'] = $displayoptions->marks;
$params['markdp'] = $displayoptions->markdp;
$params['feedback'] = (bool) $displayoptions->feedback;
$params['generalfeedback'] = (bool) $displayoptions->generalfeedback;
$params['rightanswer'] = (bool) $displayoptions->rightanswer;
$params['history'] = (bool) $displayoptions->history;
}
if ($variant) {
$params['variant'] = $variant;
}
return new moodle_url('/question/preview.php', $params);
}
/**
* @return array that can be passed as $params to the {@link popup_action} constructor.
*/
function question_preview_popup_params() {
return array(
'height' => 600,
'width' => 800,
);
}
/**
* Given a list of ids, load the basic information about a set of questions from
* the questions table. The $join and $extrafields arguments can be used together
* to pull in extra data. See, for example, the usage in mod/quiz/attemptlib.php, and
* read the code below to see how the SQL is assembled. Throws exceptions on error.
*
* @param array $questionids array of question ids to load. If null, then all
* questions matched by $join will be loaded.
* @param string $extrafields extra SQL code to be added to the query.
* @param string $join extra SQL code to be added to the query.
* @param array $extraparams values for any placeholders in $join.
* You must use named placeholders.
* @param string $orderby what to order the results by. Optional, default is unspecified order.
*
* @return array partially complete question objects. You need to call get_question_options
* on them before they can be properly used.
*/
function question_preload_questions($questionids = null, $extrafields = '', $join = '',
$extraparams = array(), $orderby = '') {
global $DB;
if ($questionids === null) {
$where = '';
$params = array();
} else {
if (empty($questionids)) {
return array();
}
list($questionidcondition, $params) = $DB->get_in_or_equal(
$questionids, SQL_PARAMS_NAMED, 'qid0000');
$where = 'WHERE q.id ' . $questionidcondition;
}
if ($join) {
$join = 'JOIN ' . $join;
}
if ($extrafields) {
$extrafields = ', ' . $extrafields;
}
if ($orderby) {
$orderby = 'ORDER BY ' . $orderby;
}
$sql = "SELECT q.*, qc.contextid{$extrafields}
FROM {question} q
JOIN {question_categories} qc ON q.category = qc.id
{$join}
{$where}
{$orderby}";
// Load the questions.
$questions = $DB->get_records_sql($sql, $extraparams + $params);
foreach ($questions as $question) {
$question->_partiallyloaded = true;
}
return $questions;
}
/**
* Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
* together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
* read the code below to see how the SQL is assembled. Throws exceptions on error.
*
* @param array $questionids array of question ids.
* @param string $extrafields extra SQL code to be added to the query.
* @param string $join extra SQL code to be added to the query.
* @param array $extraparams values for any placeholders in $join.
* You are strongly recommended to use named placeholder.
*
* @return array question objects.
*/
function question_load_questions($questionids, $extrafields = '', $join = '') {
$questions = question_preload_questions($questionids, $extrafields, $join);
// Load the question type specific information
if (!get_question_options($questions)) {
return 'Could not load the question options';
}
return $questions;
}
/**
* Private function to factor common code out of get_question_options().
*
* @param object $question the question to tidy.
* @param stdClass $category The question_categories record for the given $question.
* @param stdClass[]|null $tagobjects The tags for the given $question.
* @param stdClass[]|null $filtercourses The courses to filter the course tags by.
*/
function _tidy_question($question, $category, array $tagobjects = null, array $filtercourses = null) {
global $CFG;
// Load question-type specific fields.
if (!question_bank::is_qtype_installed($question->qtype)) {
$question->questiontext = html_writer::tag('p', get_string('warningmissingtype',
'qtype_missingtype')) . $question->questiontext;
}
question_bank::get_qtype($question->qtype)->get_question_options($question);
// Convert numeric fields to float. (Prevents these being displayed as 1.0000000.)
$question->defaultmark += 0;
$question->penalty += 0;
if (isset($question->_partiallyloaded)) {
unset($question->_partiallyloaded);
}
$question->categoryobject = $category;
if (!is_null($tagobjects)) {
$categorycontext = context::instance_by_id($category->contextid);
$sortedtagobjects = question_sort_tags($tagobjects, $categorycontext, $filtercourses);
$question->coursetagobjects = $sortedtagobjects->coursetagobjects;
$question->coursetags = $sortedtagobjects->coursetags;
$question->tagobjects = $sortedtagobjects->tagobjects;
$question->tags = $sortedtagobjects->tags;
}
}
/**
* Updates the question objects with question type specific
* information by calling {@link get_question_options()}
*
* Can be called either with an array of question objects or with a single
* question object.
*
* @param mixed $questions Either an array of question objects to be updated
* or just a single question object
* @param bool $loadtags load the question tags from the tags table. Optional, default false.
* @param stdClass[] $filtercourses The courses to filter the course tags by.
* @return bool Indicates success or failure.
*/
function get_question_options(&$questions, $loadtags = false, $filtercourses = null) {
global $DB;
$questionlist = is_array($questions) ? $questions : [$questions];
$categoryids = [];
$questionids = [];
if (empty($questionlist)) {
return true;
}
foreach ($questionlist as $question) {
$questionids[] = $question->id;
if (!in_array($question->category, $categoryids)) {
$categoryids[] = $question->category;
}
}
$categories = $DB->get_records_list('question_categories', 'id', $categoryids);
if ($loadtags && core_tag_tag::is_enabled('core_question', 'question')) {
$tagobjectsbyquestion = core_tag_tag::get_items_tags('core_question', 'question', $questionids);
} else {
$tagobjectsbyquestion = null;
}
foreach ($questionlist as $question) {
if (is_null($tagobjectsbyquestion)) {
$tagobjects = null;
} else {
$tagobjects = $tagobjectsbyquestion[$question->id];
}
_tidy_question($question, $categories[$question->category], $tagobjects, $filtercourses);
}
return true;
}
/**
* Sort question tags by course or normal tags.
*
* This function also search tag instances that may have a context id that don't match either a course or
* question context and fix the data setting the correct context id.
*
* @param stdClass[] $tagobjects The tags for the given $question.
* @param stdClass $categorycontext The question categories context.
* @param stdClass[]|null $filtercourses The courses to filter the course tags by.
* @return stdClass $sortedtagobjects Sorted tag objects.
*/
function question_sort_tags($tagobjects, $categorycontext, $filtercourses = null) {
// Questions can have two sets of tag instances. One set at the
// course context level and another at the context the question
// belongs to (e.g. course category, system etc).