Automatic installer lang files (20100904)
[moodle.git] / lib / questionlib.php
blobc0c3abb4d715ee455a1d12c78dfc5bcc5159fcc9
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 // GNU General Public License for more details.
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
18 /**
19 * Code for handling and processing questions
21 * This is code that is module independent, i.e., can be used by any module that
22 * uses questions, like quiz, lesson, ..
23 * This script also loads the questiontype classes
24 * Code for handling the editing of questions is in {@link question/editlib.php}
26 * TODO: separate those functions which form part of the API
27 * from the helper functions.
29 * Major Contributors
30 * - Alex Smith, Julian Sedding and Gustav Delius {@link http://maths.york.ac.uk/serving_maths}
32 * @package core
33 * @subpackage question
34 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
35 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38 defined('MOODLE_INTERNAL') || die();
40 /// CONSTANTS ///////////////////////////////////
42 /**#@+
43 * The different types of events that can create question states
45 define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle
46 define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used)
47 define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated
48 define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle.
49 define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously
50 define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded.
51 define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle.
52 define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked
53 define('QUESTION_EVENTCLOSE', '8'); // The response has been submitted and the session has been closed, either because the student requested it or because Moodle did it (e.g. because of a timelimit). The responses have not been graded.
54 define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher
56 define('QUESTION_EVENTS_GRADED', QUESTION_EVENTGRADE.','.
57 QUESTION_EVENTCLOSEANDGRADE.','.
58 QUESTION_EVENTMANUALGRADE);
61 define('QUESTION_EVENTS_CLOSED', QUESTION_EVENTCLOSE.','.
62 QUESTION_EVENTCLOSEANDGRADE.','.
63 QUESTION_EVENTMANUALGRADE);
65 define('QUESTION_EVENTS_CLOSED_OR_GRADED', QUESTION_EVENTGRADE.','.
66 QUESTION_EVENTS_CLOSED);
68 /**#@-*/
70 /**#@+
71 * The core question types.
73 define("SHORTANSWER", "shortanswer");
74 define("TRUEFALSE", "truefalse");
75 define("MULTICHOICE", "multichoice");
76 define("RANDOM", "random");
77 define("MATCH", "match");
78 define("RANDOMSAMATCH", "randomsamatch");
79 define("DESCRIPTION", "description");
80 define("NUMERICAL", "numerical");
81 define("MULTIANSWER", "multianswer");
82 define("CALCULATED", "calculated");
83 define("ESSAY", "essay");
84 /**#@-*/
86 /**
87 * Constant determines the number of answer boxes supplied in the editing
88 * form for multiple choice and similar question types.
90 define("QUESTION_NUMANS", "10");
92 /**
93 * Constant determines the number of answer boxes supplied in the editing
94 * form for multiple choice and similar question types to start with, with
95 * the option of adding QUESTION_NUMANS_ADD more answers.
97 define("QUESTION_NUMANS_START", 3);
99 /**
100 * Constant determines the number of answer boxes to add in the editing
101 * form for multiple choice and similar question types when the user presses
102 * 'add form fields button'.
104 define("QUESTION_NUMANS_ADD", 3);
107 * The options used when popping up a question preview window in Javascript.
109 define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes&resizable=yes&width=700&height=540');
111 /**#@+
112 * Option flags for ->optionflags
113 * The options are read out via bitwise operation using these constants
116 * Whether the questions is to be run in adaptive mode. If this is not set then
117 * a question closes immediately after the first submission of responses. This
118 * is how question is Moodle always worked before version 1.5
120 define('QUESTION_ADAPTIVE', 1);
121 /**#@-*/
123 /**#@+
124 * Options used in forms that move files.
126 define('QUESTION_FILENOTHINGSELECTED', 0);
127 define('QUESTION_FILEDONOTHING', 1);
128 define('QUESTION_FILECOPY', 2);
129 define('QUESTION_FILEMOVE', 3);
130 define('QUESTION_FILEMOVELINKSONLY', 4);
131 /**#@-*/
133 /**#@+
134 * Options for whether flags are shown/editable when rendering questions.
136 define('QUESTION_FLAGSHIDDEN', 0);
137 define('QUESTION_FLAGSSHOWN', 1);
138 define('QUESTION_FLAGSEDITABLE', 2);
139 /**#@-*/
142 * GLOBAL VARAIBLES
143 * @global array $QTYPES
144 * @name $QTYPES
146 global $QTYPES;
148 * Array holding question type objects. Initialised via calls to
149 * question_register_questiontype as the question type classes are included.
151 $QTYPES = array();
154 * Add a new question type to the various global arrays above.
156 * @global object
157 * @param object $qtype An instance of the new question type class.
159 function question_register_questiontype($qtype) {
160 global $QTYPES;
162 $name = $qtype->name();
163 $QTYPES[$name] = $qtype;
166 require_once("$CFG->dirroot/question/type/questiontype.php");
168 // Load the questiontype.php file for each question type
169 // These files in turn call question_register_questiontype()
170 // with a new instance of each qtype class.
171 $qtypenames = get_plugin_list('qtype');
172 foreach($qtypenames as $qtypename => $qdir) {
173 // Instanciates all plug-in question types
174 $qtypefilepath= "$qdir/questiontype.php";
176 // echo "Loading $qtypename<br/>"; // Uncomment for debugging
177 if (is_readable($qtypefilepath)) {
178 require_once($qtypefilepath);
183 * An array of question type names translated to the user's language, suitable for use when
184 * creating a drop-down menu of options.
186 * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
187 * The array returned will only hold the names of all the question types that the user should
188 * be able to create directly. Some internal question types like random questions are excluded.
190 * @global object
191 * @return array an array of question type names translated to the user's language.
193 function question_type_menu() {
194 global $QTYPES;
195 static $menuoptions = null;
196 if (is_null($menuoptions)) {
197 $config = get_config('question');
198 $menuoptions = array();
199 foreach ($QTYPES as $name => $qtype) {
200 // Get the name if this qtype is enabled.
201 $menuname = $qtype->menu_name();
202 $enabledvar = $name . '_disabled';
203 if ($menuname && !isset($config->$enabledvar)) {
204 $menuoptions[$name] = $menuname;
208 $menuoptions = question_sort_qtype_array($menuoptions, $config);
210 return $menuoptions;
214 * Sort an array of question type names according to the question type sort order stored in
215 * config_plugins. Entries for which there is no xxx_sortorder defined will go
216 * at the end, sorted according to asort($inarray, SORT_LOCALE_STRING).
217 * @param $inarray an array $qtype => $QTYPES[$qtype]->local_name().
218 * @param $config get_config('question'), if you happen to have it around, to save one DB query.
219 * @return array the sorted version of $inarray.
221 function question_sort_qtype_array($inarray, $config = null) {
222 if (is_null($config)) {
223 $config = get_config('question');
226 $sortorder = array();
227 foreach ($inarray as $name => $notused) {
228 $sortvar = $name . '_sortorder';
229 if (isset($config->$sortvar)) {
230 $sortorder[$config->$sortvar] = $name;
234 ksort($sortorder);
235 $outarray = array();
236 foreach ($sortorder as $name) {
237 $outarray[$name] = $inarray[$name];
238 unset($inarray[$name]);
240 asort($inarray, SORT_LOCALE_STRING);
241 return array_merge($outarray, $inarray);
245 * Move one question type in a list of question types. If you try to move one element
246 * off of the end, nothing will change.
248 * @param array $sortedqtypes An array $qtype => anything.
249 * @param string $tomove one of the keys from $sortedqtypes
250 * @param integer $direction +1 or -1
251 * @return array an array $index => $qtype, with $index from 0 to n in order, and
252 * the $qtypes in the same order as $sortedqtypes, except that $tomove will
253 * have been moved one place.
255 function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
256 $neworder = array_keys($sortedqtypes);
257 // Find the element to move.
258 $key = array_search($tomove, $neworder);
259 if ($key === false) {
260 return $neworder;
262 // Work out the other index.
263 $otherkey = $key + $direction;
264 if (!isset($neworder[$otherkey])) {
265 return $neworder;
267 // Do the swap.
268 $swap = $neworder[$otherkey];
269 $neworder[$otherkey] = $neworder[$key];
270 $neworder[$key] = $swap;
271 return $neworder;
275 * Save a new question type order to the config_plugins table.
276 * @global object
277 * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
278 * @param $config get_config('question'), if you happen to have it around, to save one DB query.
280 function question_save_qtype_order($neworder, $config = null) {
281 global $DB;
283 if (is_null($config)) {
284 $config = get_config('question');
287 foreach ($neworder as $index => $qtype) {
288 $sortvar = $qtype . '_sortorder';
289 if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
290 set_config($sortvar, $index + 1, 'question');
295 /// OTHER CLASSES /////////////////////////////////////////////////////////
298 * This holds the options that are set by the course module
300 * @package moodlecore
301 * @subpackage question
302 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
303 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
305 class cmoptions {
307 * Whether a new attempt should be based on the previous one. If true
308 * then a new attempt will start in a state where all responses are set
309 * to the last responses from the previous attempt.
311 var $attemptonlast = false;
314 * Various option flags. The flags are accessed via bitwise operations
315 * using the constants defined in the CONSTANTS section above.
317 var $optionflags = QUESTION_ADAPTIVE;
320 * Determines whether in the calculation of the score for a question
321 * penalties for earlier wrong responses within the same attempt will
322 * be subtracted.
324 var $penaltyscheme = true;
327 * The maximum time the user is allowed to answer the questions withing
328 * an attempt. This is measured in minutes so needs to be multiplied by
329 * 60 before compared to timestamps. If set to 0 no timelimit will be applied
331 var $timelimit = 0;
334 * Timestamp for the closing time. Responses submitted after this time will
335 * be saved but no credit will be given for them.
337 var $timeclose = 9999999999;
340 * The id of the course from withing which the question is currently being used
342 var $course = SITEID;
345 * Whether the answers in a multiple choice question should be randomly
346 * shuffled when a new attempt is started.
348 var $shuffleanswers = true;
351 * The number of decimals to be shown when scores are printed
353 var $decimalpoints = 2;
357 /// FUNCTIONS //////////////////////////////////////////////////////
360 * Returns an array of names of activity modules that use this question
362 * @global object
363 * @global object
364 * @param object $questionid
365 * @return array of strings
367 function question_list_instances($questionid) {
368 global $CFG, $DB;
369 $instances = array();
370 $modules = $DB->get_records('modules');
371 foreach ($modules as $module) {
372 $fullmod = $CFG->dirroot . '/mod/' . $module->name;
373 if (file_exists($fullmod . '/lib.php')) {
374 include_once($fullmod . '/lib.php');
375 $fn = $module->name.'_question_list_instances';
376 if (function_exists($fn)) {
377 $instances = $instances + $fn($questionid);
381 return $instances;
385 * Determine whether there arey any questions belonging to this context, that is whether any of its
386 * question categories contain any questions. This will return true even if all the questions are
387 * hidden.
389 * @global object
390 * @param mixed $context either a context object, or a context id.
391 * @return boolean whether any of the question categories beloning to this context have
392 * any questions in them.
394 function question_context_has_any_questions($context) {
395 global $DB;
396 if (is_object($context)) {
397 $contextid = $context->id;
398 } else if (is_numeric($context)) {
399 $contextid = $context;
400 } else {
401 print_error('invalidcontextinhasanyquestions', 'question');
403 return $DB->record_exists_sql("SELECT *
404 FROM {question} q
405 JOIN {question_categories} qc ON qc.id = q.category
406 WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
410 * Returns list of 'allowed' grades for grade selection
411 * formatted suitably for dropdown box function
412 * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
414 function get_grade_options() {
415 // define basic array of grades. This list comprises all fractions of the form:
416 // a. p/q for q <= 6, 0 <= p <= q
417 // b. p/10 for 0 <= p <= 10
418 // c. 1/q for 1 <= q <= 10
419 // d. 1/20
420 $grades = array(
421 1.0000000,
422 0.9000000,
423 0.8333333,
424 0.8000000,
425 0.7500000,
426 0.7000000,
427 0.6666667,
428 0.6000000,
429 0.5000000,
430 0.4000000,
431 0.3333333,
432 0.3000000,
433 0.2500000,
434 0.2000000,
435 0.1666667,
436 0.1428571,
437 0.1250000,
438 0.1111111,
439 0.1000000,
440 0.0500000,
441 0.0000000);
443 // iterate through grades generating full range of options
444 $gradeoptionsfull = array();
445 $gradeoptions = array();
446 foreach ($grades as $grade) {
447 $percentage = 100 * $grade;
448 $neggrade = -$grade;
449 $gradeoptions["$grade"] = "$percentage %";
450 $gradeoptionsfull["$grade"] = "$percentage %";
451 $gradeoptionsfull["$neggrade"] = -$percentage." %";
453 $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
455 // sort lists
456 arsort($gradeoptions, SORT_NUMERIC);
457 arsort($gradeoptionsfull, SORT_NUMERIC);
459 // construct return object
460 $grades = new stdClass;
461 $grades->gradeoptions = $gradeoptions;
462 $grades->gradeoptionsfull = $gradeoptionsfull;
464 return $grades;
468 * match grade options
469 * if no match return error or match nearest
470 * @param array $gradeoptionsfull list of valid options
471 * @param int $grade grade to be tested
472 * @param string $matchgrades 'error' or 'nearest'
473 * @return mixed either 'fixed' value or false if erro
475 function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
476 // if we just need an error...
477 if ($matchgrades=='error') {
478 foreach($gradeoptionsfull as $value => $option) {
479 // slightly fuzzy test, never check floats for equality :-)
480 if (abs($grade-$value)<0.00001) {
481 return $grade;
484 // didn't find a match so that's an error
485 return false;
487 // work out nearest value
488 else if ($matchgrades=='nearest') {
489 $hownear = array();
490 foreach($gradeoptionsfull as $value => $option) {
491 if ($grade==$value) {
492 return $grade;
494 $hownear[ $value ] = abs( $grade - $value );
496 // reverse sort list of deltas and grab the last (smallest)
497 asort( $hownear, SORT_NUMERIC );
498 reset( $hownear );
499 return key( $hownear );
501 else {
502 return false;
507 * Tests whether a category is in use by any activity module
509 * @global object
510 * @return boolean
511 * @param integer $categoryid
512 * @param boolean $recursive Whether to examine category children recursively
514 function question_category_isused($categoryid, $recursive = false) {
515 global $DB;
517 //Look at each question in the category
518 if ($questions = $DB->get_records('question', array('category'=>$categoryid), '', 'id,qtype')) {
519 foreach ($questions as $question) {
520 if (count(question_list_instances($question->id))) {
521 return true;
526 //Look under child categories recursively
527 if ($recursive) {
528 if ($children = $DB->get_records('question_categories', array('parent'=>$categoryid))) {
529 foreach ($children as $child) {
530 if (question_category_isused($child->id, $recursive)) {
531 return true;
537 return false;
541 * Deletes all data associated to an attempt from the database
543 * @global object
544 * @global object
545 * @param integer $attemptid The id of the attempt being deleted
547 function delete_attempt($attemptid) {
548 global $QTYPES, $DB;
550 $states = $DB->get_records('question_states', array('attempt'=>$attemptid));
551 if ($states) {
552 $stateslist = implode(',', array_keys($states));
554 // delete question-type specific data
555 foreach ($QTYPES as $qtype) {
556 $qtype->delete_states($stateslist);
560 // delete entries from all other question tables
561 // It is important that this is done only after calling the questiontype functions
562 $DB->delete_records("question_states", array("attempt"=>$attemptid));
563 $DB->delete_records("question_sessions", array("attemptid"=>$attemptid));
564 $DB->delete_records("question_attempts", array("id"=>$attemptid));
568 * Deletes question and all associated data from the database
570 * It will not delete a question if it is used by an activity module
572 * @global object
573 * @global object
574 * @param object $question The question being deleted
576 function delete_question($questionid) {
577 global $QTYPES, $DB;
579 if (!$question = $DB->get_record('question', array('id'=>$questionid))) {
580 // In some situations, for example if this was a child of a
581 // Cloze question that was previously deleted, the question may already
582 // have gone. In this case, just do nothing.
583 return;
586 // Do not delete a question if it is used by an activity module
587 if (count(question_list_instances($questionid))) {
588 return;
591 // delete questiontype-specific data
592 question_require_capability_on($question, 'edit');
593 if ($question) {
594 if (isset($QTYPES[$question->qtype])) {
595 $QTYPES[$question->qtype]->delete_question($questionid);
597 } else {
598 echo "Question with id $questionid does not exist.<br />";
601 if ($states = $DB->get_records('question_states', array('question'=>$questionid))) {
602 $stateslist = implode(',', array_keys($states));
604 // delete questiontype-specific data
605 foreach ($QTYPES as $qtype) {
606 $qtype->delete_states($stateslist);
610 // delete entries from all other question tables
611 // It is important that this is done only after calling the questiontype functions
612 $DB->delete_records("question_answers", array("question"=>$questionid));
613 $DB->delete_records("question_states", array("question"=>$questionid));
614 $DB->delete_records("question_sessions", array("questionid"=>$questionid));
616 // Now recursively delete all child questions
617 if ($children = $DB->get_records('question', array('parent' => $questionid), '', 'id,qtype')) {
618 foreach ($children as $child) {
619 if ($child->id != $questionid) {
620 delete_question($child->id);
625 // Finally delete the question record itself
626 $DB->delete_records('question', array('id'=>$questionid));
628 return;
632 * All question categories and their questions are deleted for this course.
634 * @global object
635 * @param object $mod an object representing the activity
636 * @param boolean $feedback to specify if the process must output a summary of its work
637 * @return boolean
639 function question_delete_course($course, $feedback=true) {
640 global $DB, $OUTPUT;
642 //To store feedback to be showed at the end of the process
643 $feedbackdata = array();
645 //Cache some strings
646 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
647 $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
648 $categoriescourse = $DB->get_records('question_categories', array('contextid'=>$coursecontext->id), 'parent', 'id, parent, name');
650 if ($categoriescourse) {
652 //Sort categories following their tree (parent-child) relationships
653 //this will make the feedback more readable
654 $categoriescourse = sort_categories_by_tree($categoriescourse);
656 foreach ($categoriescourse as $category) {
658 //Delete it completely (questions and category itself)
659 //deleting questions
660 if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
661 foreach ($questions as $question) {
662 delete_question($question->id);
664 $DB->delete_records("question", array("category"=>$category->id));
666 //delete the category
667 $DB->delete_records('question_categories', array('id'=>$category->id));
669 //Fill feedback
670 $feedbackdata[] = array($category->name, $strcatdeleted);
672 //Inform about changes performed if feedback is enabled
673 if ($feedback) {
674 $table = new html_table();
675 $table->head = array(get_string('category','quiz'), get_string('action'));
676 $table->data = $feedbackdata;
677 echo html_writer::table($table);
680 return true;
684 * Category is about to be deleted,
685 * 1/ All question categories and their questions are deleted for this course category.
686 * 2/ All questions are moved to new category
688 * @global object
689 * @param object $category course category object
690 * @param object $newcategory empty means everything deleted, otherwise id of category where content moved
691 * @param boolean $feedback to specify if the process must output a summary of its work
692 * @return boolean
694 function question_delete_course_category($category, $newcategory, $feedback=true) {
695 global $DB, $OUTPUT;
697 $context = get_context_instance(CONTEXT_COURSECAT, $category->id);
698 if (empty($newcategory)) {
699 $feedbackdata = array(); // To store feedback to be showed at the end of the process
700 $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
701 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
703 // Loop over question categories.
704 if ($categories = $DB->get_records('question_categories', array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
705 foreach ($categories as $category) {
707 // Deal with any questions in the category.
708 if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
710 // Try to delete each question.
711 foreach ($questions as $question) {
712 delete_question($question->id);
715 // Check to see if there were any questions that were kept because they are
716 // still in use somehow, even though quizzes in courses in this category will
717 // already have been deteted. This could happen, for example, if questions are
718 // added to a course, and then that course is moved to another category (MDL-14802).
719 $questionids = $DB->get_records_menu('question', array('category'=>$category->id), '', 'id,1');
720 if (!empty($questionids)) {
721 if (!$rescueqcategory = question_save_from_deletion(implode(',', array_keys($questionids)),
722 get_parent_contextid($context), print_context_name($context), $rescueqcategory)) {
723 return false;
725 $feedbackdata[] = array($category->name, get_string('questionsmovedto', 'question', $rescueqcategory->name));
729 // Now delete the category.
730 if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
731 return false;
733 $feedbackdata[] = array($category->name, $strcatdeleted);
735 } // End loop over categories.
738 // Output feedback if requested.
739 if ($feedback and $feedbackdata) {
740 $table = new html_table();
741 $table->head = array(get_string('questioncategory','question'), get_string('action'));
742 $table->data = $feedbackdata;
743 echo html_writer::table($table);
746 } else {
747 // Move question categories ot the new context.
748 if (!$newcontext = get_context_instance(CONTEXT_COURSECAT, $newcategory->id)) {
749 return false;
751 $DB->set_field('question_categories', 'contextid', $newcontext->id, array('contextid'=>$context->id));
752 if ($feedback) {
753 $a = new stdClass;
754 $a->oldplace = print_context_name($context);
755 $a->newplace = print_context_name($newcontext);
756 echo $OUTPUT->notification(get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
760 return true;
764 * Enter description here...
766 * @global object
767 * @param string $questionids list of questionids
768 * @param object $newcontext the context to create the saved category in.
769 * @param string $oldplace a textual description of the think being deleted, e.g. from get_context_name
770 * @param object $newcategory
771 * @return mixed false on
773 function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) {
774 global $DB;
776 // Make a category in the parent context to move the questions to.
777 if (is_null($newcategory)) {
778 $newcategory = new object();
779 $newcategory->parent = 0;
780 $newcategory->contextid = $newcontextid;
781 $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
782 $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
783 $newcategory->sortorder = 999;
784 $newcategory->stamp = make_unique_id_code();
785 $newcategory->id = $DB->insert_record('question_categories', $newcategory);
788 // Move any remaining questions to the 'saved' category.
789 if (!question_move_questions_to_category($questionids, $newcategory->id)) {
790 return false;
792 return $newcategory;
796 * All question categories and their questions are deleted for this activity.
798 * @global object
799 * @param object $cm the course module object representing the activity
800 * @param boolean $feedback to specify if the process must output a summary of its work
801 * @return boolean
803 function question_delete_activity($cm, $feedback=true) {
804 global $DB, $OUTPUT;
806 //To store feedback to be showed at the end of the process
807 $feedbackdata = array();
809 //Cache some strings
810 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
811 $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
812 if ($categoriesmods = $DB->get_records('question_categories', array('contextid'=>$modcontext->id), 'parent', 'id, parent, name')){
813 //Sort categories following their tree (parent-child) relationships
814 //this will make the feedback more readable
815 $categoriesmods = sort_categories_by_tree($categoriesmods);
817 foreach ($categoriesmods as $category) {
819 //Delete it completely (questions and category itself)
820 //deleting questions
821 if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
822 foreach ($questions as $question) {
823 delete_question($question->id);
825 $DB->delete_records("question", array("category"=>$category->id));
827 //delete the category
828 $DB->delete_records('question_categories', array('id'=>$category->id));
830 //Fill feedback
831 $feedbackdata[] = array($category->name, $strcatdeleted);
833 //Inform about changes performed if feedback is enabled
834 if ($feedback) {
835 $table = new html_table();
836 $table->head = array(get_string('category','quiz'), get_string('action'));
837 $table->data = $feedbackdata;
838 echo html_writer::table($table);
841 return true;
845 * This function should be considered private to the question bank, it is called from
846 * question/editlib.php question/contextmoveq.php and a few similar places to to the work of
847 * acutally moving questions and associated data. However, callers of this function also have to
848 * do other work, which is why you should not call this method directly from outside the questionbank.
850 * @global object
851 * @param string $questionids a comma-separated list of question ids.
852 * @param integer $newcategoryid the id of the category to move to.
854 function question_move_questions_to_category($questionids, $newcategoryid) {
855 global $DB, $QTYPES;
857 $ids = explode(',', $questionids);
858 foreach ($ids as $questionid) {
859 $questionid = (int)$questionid;
860 $params = array();
861 $params[] = $questionid;
862 $sql = 'SELECT q.*, c.id AS contextid, c.contextlevel, c.instanceid, c.path, c.depth
863 FROM {question} q, {question_categories} qc, {context} c
864 WHERE q.category=qc.id AND q.id=? AND qc.contextid=c.id';
865 $question = $DB->get_record_sql($sql, $params);
866 $category = $DB->get_record('question_categories', array('id'=>$newcategoryid));
867 // process files
868 $QTYPES[$question->qtype]->move_files($question, $category);
872 // Move the questions themselves.
873 $DB->set_field_select('question', 'category', $newcategoryid, "id IN ($questionids)");
875 // Move any subquestions belonging to them.
876 $DB->set_field_select('question', 'category', $newcategoryid, "parent IN ($questionids)");
878 // TODO Deal with datasets.
880 return true;
884 * Given a list of ids, load the basic information about a set of questions from the questions table.
885 * The $join and $extrafields arguments can be used together to pull in extra data.
886 * See, for example, the usage in mod/quiz/attemptlib.php, and
887 * read the code below to see how the SQL is assembled. Throws exceptions on error.
889 * @global object
890 * @global object
891 * @param array $questionids array of question ids.
892 * @param string $extrafields extra SQL code to be added to the query.
893 * @param string $join extra SQL code to be added to the query.
894 * @param array $extraparams values for any placeholders in $join.
895 * You are strongly recommended to use named placeholder.
897 * @return array partially complete question objects. You need to call get_question_options
898 * on them before they can be properly used.
900 function question_preload_questions($questionids, $extrafields = '', $join = '', $extraparams = array()) {
901 global $CFG, $DB;
902 if (empty($questionids)) {
903 return array();
905 if ($join) {
906 $join = ' JOIN '.$join;
908 if ($extrafields) {
909 $extrafields = ', ' . $extrafields;
911 list($questionidcondition, $params) = $DB->get_in_or_equal(
912 $questionids, SQL_PARAMS_NAMED, 'qid0000');
913 $sql = 'SELECT q.*' . $extrafields . ' FROM {question} q' . $join .
914 ' WHERE q.id ' . $questionidcondition;
916 // Load the questions
917 if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) {
918 return 'Could not load questions.';
921 foreach ($questions as $question) {
922 $question->_partiallyloaded = true;
925 // Note, a possible optimisation here would be to not load the TEXT fields
926 // (that is, questiontext and generalfeedback) here, and instead load them in
927 // question_load_questions. That would add one DB query, but reduce the amount
928 // of data transferred most of the time. I am not going to do this optimisation
929 // until it is shown to be worthwhile.
931 return $questions;
935 * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
936 * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
937 * read the code below to see how the SQL is assembled. Throws exceptions on error.
939 * @param array $questionids array of question ids.
940 * @param string $extrafields extra SQL code to be added to the query.
941 * @param string $join extra SQL code to be added to the query.
942 * @param array $extraparams values for any placeholders in $join.
943 * You are strongly recommended to use named placeholder.
945 * @return array question objects.
947 function question_load_questions($questionids, $extrafields = '', $join = '') {
948 $questions = question_preload_questions($questionids, $extrafields, $join);
950 // Load the question type specific information
951 if (!get_question_options($questions)) {
952 return 'Could not load the question options';
955 return $questions;
959 * Private function to factor common code out of get_question_options().
961 * @global object
962 * @global object
963 * @param object $question the question to tidy.
964 * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
965 * @return boolean true if successful, else false.
967 function _tidy_question(&$question, $loadtags = false) {
968 global $CFG, $QTYPES;
969 if (!array_key_exists($question->qtype, $QTYPES)) {
970 $question->qtype = 'missingtype';
971 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
973 $question->name_prefix = question_make_name_prefix($question->id);
974 if ($success = $QTYPES[$question->qtype]->get_question_options($question)) {
975 if (isset($question->_partiallyloaded)) {
976 unset($question->_partiallyloaded);
979 if ($loadtags && !empty($CFG->usetags)) {
980 require_once($CFG->dirroot . '/tag/lib.php');
981 $question->tags = tag_get_tags_array('question', $question->id);
983 return $success;
987 * Updates the question objects with question type specific
988 * information by calling {@link get_question_options()}
990 * Can be called either with an array of question objects or with a single
991 * question object.
993 * @param mixed $questions Either an array of question objects to be updated
994 * or just a single question object
995 * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
996 * @return bool Indicates success or failure.
998 function get_question_options(&$questions, $loadtags = false) {
999 if (is_array($questions)) { // deal with an array of questions
1000 foreach ($questions as $i => $notused) {
1001 if (!_tidy_question($questions[$i], $loadtags)) {
1002 return false;
1005 return true;
1006 } else { // deal with single question
1007 return _tidy_question($questions, $loadtags);
1012 * Load the basic state information for
1014 * @global object
1015 * @param integer $attemptid the attempt id to load the states for.
1016 * @return array an array of state data from the database, you will subsequently
1017 * need to call question_load_states to get fully loaded states that can be
1018 * used by the question types. The states here should be sufficient for
1019 * basic tasks like rendering navigation.
1021 function question_preload_states($attemptid) {
1022 global $DB;
1023 // Note, changes here probably also need to be reflected in
1024 // regrade_question_in_attempt and question_load_specific_state.
1026 // The questionid field must be listed first so that it is used as the
1027 // array index in the array returned by $DB->get_records_sql
1028 $statefields = 'n.questionid as question, s.id, s.attempt, ' .
1029 's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' .
1030 's.penalty, n.sumpenalty, n.manualcomment, n.flagged, n.id as questionsessionid';
1032 // Load the newest states for the questions
1033 $sql = "SELECT $statefields
1034 FROM {question_states} s, {question_sessions} n
1035 WHERE s.id = n.newest AND n.attemptid = ?";
1036 $states = $DB->get_records_sql($sql, array($attemptid));
1037 if (!$states) {
1038 return false;
1041 // Load the newest graded states for the questions
1042 $sql = "SELECT $statefields
1043 FROM {question_states} s, {question_sessions} n
1044 WHERE s.id = n.newgraded AND n.attemptid = ?";
1045 $gradedstates = $DB->get_records_sql($sql, array($attemptid));
1047 // Hook the two together.
1048 foreach ($states as $questionid => $state) {
1049 $states[$questionid]->_partiallyloaded = true;
1050 if ($gradedstates[$questionid]) {
1051 $states[$questionid]->last_graded = $gradedstates[$questionid];
1052 $states[$questionid]->last_graded->_partiallyloaded = true;
1056 return $states;
1060 * Finish loading the question states that were extracted from the database with
1061 * question_preload_states, creating new states for any question where there
1062 * is not a state in the database.
1064 * @global object
1065 * @global object
1066 * @param array $questions the questions to load state for.
1067 * @param array $states the partially loaded states this array is updated.
1068 * @param object $cmoptions options from the module we are loading the states for. E.g. $quiz.
1069 * @param object $attempt The attempt for which the question sessions are
1070 * to be restored or created.
1071 * @param mixed either the id of a previous attempt, if this attmpt is
1072 * building on a previous one, or false for a clean attempt.
1073 * @return true or false for success or failure.
1075 function question_load_states(&$questions, &$states, $cmoptions, $attempt, $lastattemptid = false) {
1076 global $QTYPES, $DB;
1078 // loop through all questions and set the last_graded states
1079 foreach (array_keys($questions) as $qid) {
1080 if (isset($states[$qid])) {
1081 restore_question_state($questions[$qid], $states[$qid]);
1082 if (isset($states[$qid]->_partiallyloaded)) {
1083 unset($states[$qid]->_partiallyloaded);
1085 if (isset($states[$qid]->last_graded)) {
1086 restore_question_state($questions[$qid], $states[$qid]->last_graded);
1087 if (isset($states[$qid]->last_graded->_partiallyloaded)) {
1088 unset($states[$qid]->last_graded->_partiallyloaded);
1090 } else {
1091 $states[$qid]->last_graded = clone($states[$qid]);
1093 } else {
1095 if ($lastattemptid) {
1096 // If the new attempt is to be based on this previous attempt.
1097 // Find the responses from the previous attempt and save them to the new session
1099 // Load the last graded state for the question. Note, $statefields is
1100 // the same as above, except that we don't want n.manualcomment.
1101 $statefields = 'n.questionid as question, s.id, s.attempt, ' .
1102 's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' .
1103 's.penalty, n.sumpenalty';
1104 $sql = "SELECT $statefields
1105 FROM {question_states} s, {question_sessions} n
1106 WHERE s.id = n.newest
1107 AND n.attemptid = ?
1108 AND n.questionid = ?";
1109 if (!$laststate = $DB->get_record_sql($sql, array($lastattemptid, $qid))) {
1110 // Only restore previous responses that have been graded
1111 continue;
1113 // Restore the state so that the responses will be restored
1114 restore_question_state($questions[$qid], $laststate);
1115 $states[$qid] = clone($laststate);
1116 unset($states[$qid]->id);
1117 } else {
1118 // create a new empty state
1119 $states[$qid] = new object;
1120 $states[$qid]->question = $qid;
1121 $states[$qid]->responses = array('' => '');
1122 $states[$qid]->raw_grade = 0;
1125 // now fill/overide initial values
1126 $states[$qid]->attempt = $attempt->uniqueid;
1127 $states[$qid]->seq_number = 0;
1128 $states[$qid]->timestamp = $attempt->timestart;
1129 $states[$qid]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
1130 $states[$qid]->grade = 0;
1131 $states[$qid]->penalty = 0;
1132 $states[$qid]->sumpenalty = 0;
1133 $states[$qid]->manualcomment = '';
1134 $states[$qid]->flagged = 0;
1136 // Prevent further changes to the session from incrementing the
1137 // sequence number
1138 $states[$qid]->changed = true;
1140 if ($lastattemptid) {
1141 // prepare the previous responses for new processing
1142 $action = new stdClass;
1143 $action->responses = $laststate->responses;
1144 $action->timestamp = $laststate->timestamp;
1145 $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631
1147 // Process these responses ...
1148 question_process_responses($questions[$qid], $states[$qid], $action, $cmoptions, $attempt);
1150 // Fix for Bug #5506: When each attempt is built on the last one,
1151 // preserve the options from any previous attempt.
1152 if ( isset($laststate->options) ) {
1153 $states[$qid]->options = $laststate->options;
1155 } else {
1156 // Create the empty question type specific information
1157 if (!$QTYPES[$questions[$qid]->qtype]->create_session_and_responses(
1158 $questions[$qid], $states[$qid], $cmoptions, $attempt)) {
1159 return false;
1162 $states[$qid]->last_graded = clone($states[$qid]);
1165 return true;
1169 * Loads the most recent state of each question session from the database
1170 * or create new one.
1172 * For each question the most recent session state for the current attempt
1173 * is loaded from the question_states table and the question type specific data and
1174 * responses are added by calling {@link restore_question_state()} which in turn
1175 * calls {@link restore_session_and_responses()} for each question.
1176 * If no states exist for the question instance an empty state object is
1177 * created representing the start of a session and empty question
1178 * type specific information and responses are created by calling
1179 * {@link create_session_and_responses()}.
1181 * @return array An array of state objects representing the most recent
1182 * states of the question sessions.
1183 * @param array $questions The questions for which sessions are to be restored or
1184 * created.
1185 * @param object $cmoptions
1186 * @param object $attempt The attempt for which the question sessions are
1187 * to be restored or created.
1188 * @param mixed either the id of a previous attempt, if this attmpt is
1189 * building on a previous one, or false for a clean attempt.
1191 function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) {
1192 // Preload the states.
1193 $states = question_preload_states($attempt->uniqueid);
1194 if (!$states) {
1195 $states = array();
1198 // Then finish the job.
1199 if (!question_load_states($questions, $states, $cmoptions, $attempt, $lastattemptid)) {
1200 return false;
1203 return $states;
1207 * Load a particular previous state of a question.
1209 * @global object
1210 * @param array $question The question to load the state for.
1211 * @param object $cmoptions Options from the specifica activity module, e.g. $quiz.
1212 * @param object $attempt The attempt for which the question sessions are to be loaded.
1213 * @param integer $stateid The id of a specific state of this question.
1214 * @return object the requested state. False on error.
1216 function question_load_specific_state($question, $cmoptions, $attempt, $stateid) {
1217 global $DB;
1218 // Load specified states for the question.
1219 // sess.sumpenalty is probably wrong here shoul really be a sum of penalties from before the one we are asking for.
1220 $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.flagged, sess.id as questionsessionid
1221 FROM {question_states} st, {question_sessions} sess
1222 WHERE st.id = ?
1223 AND st.attempt = ?
1224 AND sess.attemptid = st.attempt
1225 AND st.question = ?
1226 AND sess.questionid = st.question';
1227 $state = $DB->get_record_sql($sql, array($stateid, $attempt->id, $question->id));
1228 if (!$state) {
1229 return false;
1231 restore_question_state($question, $state);
1233 // Load the most recent graded states for the questions before the specified one.
1234 $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.flagged, sess.id as questionsessionid
1235 FROM {question_states} st, {question_sessions} sess
1236 WHERE st.seq_number <= ?
1237 AND st.attempt = ?
1238 AND sess.attemptid = st.attempt
1239 AND st.question = ?
1240 AND sess.questionid = st.question
1241 AND st.event IN ('.QUESTION_EVENTS_GRADED.') '.
1242 'ORDER BY st.seq_number DESC';
1243 $gradedstates = $DB->get_records_sql($sql, array($state->seq_number, $attempt->id, $question->id), 0, 1);
1244 if (empty($gradedstates)) {
1245 $state->last_graded = clone($state);
1246 } else {
1247 $gradedstate = reset($gradedstates);
1248 restore_question_state($question, $gradedstate);
1249 $state->last_graded = $gradedstate;
1251 return $state;
1255 * Creates the run-time fields for the states
1257 * Extends the state objects for a question by calling
1258 * {@link restore_session_and_responses()}
1260 * @global object
1261 * @param object $question The question for which the state is needed
1262 * @param object $state The state as loaded from the database
1263 * @return boolean Represents success or failure
1265 function restore_question_state(&$question, &$state) {
1266 global $QTYPES;
1268 // initialise response to the value in the answer field
1269 $state->responses = array('' => $state->answer);
1270 unset($state->answer);
1271 $state->manualcomment = isset($state->manualcomment) ? $state->manualcomment : '';
1273 // Set the changed field to false; any code which changes the
1274 // question session must set this to true and must increment
1275 // ->seq_number. The save_question_session
1276 // function will save the new state object to the database if the field is
1277 // set to true.
1278 $state->changed = false;
1280 // Load the question type specific data
1281 return $QTYPES[$question->qtype]
1282 ->restore_session_and_responses($question, $state);
1287 * Saves the current state of the question session to the database
1289 * The state object representing the current state of the session for the
1290 * question is saved to the question_states table with ->responses[''] saved
1291 * to the answer field of the database table. The information in the
1292 * question_sessions table is updated.
1293 * The question type specific data is then saved.
1295 * @global array
1296 * @global object
1297 * @return mixed The id of the saved or updated state or false
1298 * @param object $question The question for which session is to be saved.
1299 * @param object $state The state information to be saved. In particular the
1300 * most recent responses are in ->responses. The object
1301 * is updated to hold the new ->id.
1303 function save_question_session($question, $state) {
1304 global $QTYPES, $DB;
1306 // Check if the state has changed
1307 if (!$state->changed && isset($state->id)) {
1308 if (isset($state->newflaggedstate) && $state->flagged != $state->newflaggedstate) {
1309 // If this fails, don't worry too much, it is not critical data.
1310 question_update_flag($state->questionsessionid, $state->newflaggedstate);
1312 return $state->id;
1314 // Set the legacy answer field
1315 $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
1317 // Save the state
1318 if (!empty($state->update)) { // this forces the old state record to be overwritten
1319 $DB->update_record('question_states', $state);
1320 } else {
1321 $state->id = $DB->insert_record('question_states', $state);
1324 // create or update the session
1325 if (!$session = $DB->get_record('question_sessions', array('attemptid' => $state->attempt, 'questionid' => $question->id))) {
1326 $session = new stdClass;
1327 $session->attemptid = $state->attempt;
1328 $session->questionid = $question->id;
1329 $session->newest = $state->id;
1330 // The following may seem weird, but the newgraded field needs to be set
1331 // already even if there is no graded state yet.
1332 $session->newgraded = $state->id;
1333 $session->sumpenalty = $state->sumpenalty;
1334 $session->manualcomment = $state->manualcomment;
1335 $session->flagged = !empty($state->newflaggedstate);
1336 $DB->insert_record('question_sessions', $session);
1337 } else {
1338 $session->newest = $state->id;
1339 if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
1340 // this state is graded or newly opened, so it goes into the lastgraded field as well
1341 $session->newgraded = $state->id;
1342 $session->sumpenalty = $state->sumpenalty;
1343 $session->manualcomment = $state->manualcomment;
1344 } else {
1345 $session->manualcomment = $session->manualcomment;
1347 $session->flagged = !empty($state->newflaggedstate);
1348 $DB->update_record('question_sessions', $session);
1351 unset($state->answer);
1353 // Save the question type specific state information and responses
1354 if (!$QTYPES[$question->qtype]->save_session_and_responses($question, $state)) {
1355 return false;
1358 // Reset the changed flag
1359 $state->changed = false;
1360 return $state->id;
1364 * Determines whether a state has been graded by looking at the event field
1366 * @return boolean true if the state has been graded
1367 * @param object $state
1369 function question_state_is_graded($state) {
1370 static $question_events_graded = array();
1371 if (!$question_events_graded){
1372 $question_events_graded = explode(',', QUESTION_EVENTS_GRADED);
1374 return (in_array($state->event, $question_events_graded));
1378 * Determines whether a state has been closed by looking at the event field
1380 * @return boolean true if the state has been closed
1381 * @param object $state
1383 function question_state_is_closed($state) {
1384 static $question_events_closed = array();
1385 if (!$question_events_closed){
1386 $question_events_closed = explode(',', QUESTION_EVENTS_CLOSED);
1388 return (in_array($state->event, $question_events_closed));
1393 * Extracts responses from submitted form
1395 * This can extract the responses given to one or several questions present on a page
1396 * It returns an array with one entry for each question, indexed by question id
1397 * Each entry is an object with the properties
1398 * ->event The event that has triggered the submission. This is determined by which button
1399 * the user has pressed.
1400 * ->responses An array holding the responses to an individual question, indexed by the
1401 * name of the corresponding form element.
1402 * ->timestamp A unix timestamp
1403 * @return array array of action objects, indexed by question ids.
1404 * @param array $questions an array containing at least all questions that are used on the form
1405 * @param array $formdata the data submitted by the form on the question page
1406 * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted
1408 function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
1410 $time = time();
1411 $actions = array();
1412 foreach ($formdata as $key => $response) {
1413 // Get the question id from the response name
1414 if (false !== ($quid = question_get_id_from_name_prefix($key))) {
1415 // check if this is a valid id
1416 if (!isset($questions[$quid])) {
1417 print_error('formquestionnotinids', 'question');
1420 // Remove the name prefix from the name
1421 //decrypt trying
1422 $key = substr($key, strlen($questions[$quid]->name_prefix));
1423 if (false === $key) {
1424 $key = '';
1426 // Check for question validate and mark buttons & set events
1427 if ($key === 'validate') {
1428 $actions[$quid]->event = QUESTION_EVENTVALIDATE;
1429 } else if ($key === 'submit') {
1430 $actions[$quid]->event = QUESTION_EVENTSUBMIT;
1431 } else {
1432 $actions[$quid]->event = $defaultevent;
1434 // Update the state with the new response
1435 $actions[$quid]->responses[$key] = $response;
1437 // Set the timestamp
1438 $actions[$quid]->timestamp = $time;
1441 foreach ($actions as $quid => $notused) {
1442 ksort($actions[$quid]->responses);
1444 return $actions;
1449 * Returns the html for question feedback image.
1451 * @global object
1452 * @param float $fraction value representing the correctness of the user's
1453 * response to a question.
1454 * @param boolean $selected whether or not the answer is the one that the
1455 * user picked.
1456 * @return string
1458 function question_get_feedback_image($fraction, $selected=true) {
1459 global $CFG, $OUTPUT;
1460 static $icons = array('correct' => 'tick_green', 'partiallycorrect' => 'tick_amber',
1461 'incorrect' => 'cross_red');
1463 if ($selected) {
1464 $size = 'big';
1465 } else {
1466 $size = 'small';
1468 $class = question_get_feedback_class($fraction);
1469 return '<img src="' . $OUTPUT->pix_url('i/' . $icons[$class] . '_' . $size) .
1470 '" alt="' . get_string($class, 'quiz') . '" class="icon" />';
1474 * Returns the class name for question feedback.
1475 * @param float $fraction value representing the correctness of the user's
1476 * response to a question.
1477 * @return string
1479 function question_get_feedback_class($fraction) {
1480 if ($fraction >= 1/1.01) {
1481 return 'correct';
1482 } else if ($fraction > 0.0) {
1483 return 'partiallycorrect';
1484 } else {
1485 return 'incorrect';
1491 * For a given question in an attempt we walk the complete history of states
1492 * and recalculate the grades as we go along.
1494 * This is used when a question is changed and old student
1495 * responses need to be marked with the new version of a question.
1497 * @todo Make sure this is not quiz-specific
1499 * @global object
1500 * @return boolean Indicates whether the grade has changed
1501 * @param object $question A question object
1502 * @param object $attempt The attempt, in which the question needs to be regraded.
1503 * @param object $cmoptions
1504 * @param boolean $verbose Optional. Whether to print progress information or not.
1505 * @param boolean $dryrun Optional. Whether to make changes to grades records
1506 * or record that changes need to be made for a later regrade.
1508 function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false, $dryrun=false) {
1509 global $DB, $OUTPUT;
1511 // load all states for this question in this attempt, ordered in sequence
1512 if ($states = $DB->get_records('question_states',
1513 array('attempt'=>$attempt->uniqueid, 'question'=>$question->id),
1514 'seq_number ASC')) {
1515 $states = array_values($states);
1517 // Subtract the grade for the latest state from $attempt->sumgrades to get the
1518 // sumgrades for the attempt without this question.
1519 $attempt->sumgrades -= $states[count($states)-1]->grade;
1521 // Initialise the replaystate
1522 $replaystate = question_load_specific_state($question, $cmoptions, $attempt, $states[0]->id);
1523 $replaystate->sumpenalty = 0;
1524 $replaystate->last_graded->sumpenalty = 0;
1526 $changed = false;
1527 for($j = 1; $j < count($states); $j++) {
1528 restore_question_state($question, $states[$j]);
1529 $action = new stdClass;
1530 $action->responses = $states[$j]->responses;
1531 $action->timestamp = $states[$j]->timestamp;
1533 // Change event to submit so that it will be reprocessed
1534 if (in_array($states[$j]->event, array(QUESTION_EVENTCLOSE,
1535 QUESTION_EVENTGRADE, QUESTION_EVENTCLOSEANDGRADE))) {
1536 $action->event = QUESTION_EVENTSUBMIT;
1538 // By default take the event that was saved in the database
1539 } else {
1540 $action->event = $states[$j]->event;
1543 if ($action->event == QUESTION_EVENTMANUALGRADE) {
1544 // Ensure that the grade is in range - in the past this was not checked,
1545 // but now it is (MDL-14835) - so we need to ensure the data is valid before
1546 // proceeding.
1547 if ($states[$j]->grade < 0) {
1548 $states[$j]->grade = 0;
1549 $changed = true;
1550 } else if ($states[$j]->grade > $question->maxgrade) {
1551 $states[$j]->grade = $question->maxgrade;
1552 $changed = true;
1555 if (!$dryrun){
1556 $error = question_process_comment($question, $replaystate, $attempt,
1557 $replaystate->manualcomment, $states[$j]->grade);
1558 if (is_string($error)) {
1559 echo $OUTPUT->notification($error);
1561 } else {
1562 $replaystate->grade = $states[$j]->grade;
1564 } else {
1565 // Reprocess (regrade) responses
1566 if (!question_process_responses($question, $replaystate,
1567 $action, $cmoptions, $attempt) && $verbose) {
1568 $a = new stdClass;
1569 $a->qid = $question->id;
1570 $a->stateid = $states[$j]->id;
1571 echo $OUTPUT->notification(get_string('errorduringregrade', 'question', $a));
1573 // We need rounding here because grades in the DB get truncated
1574 // e.g. 0.33333 != 0.3333333, but we want them to be equal here
1575 if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
1576 or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
1577 or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
1578 $changed = true;
1580 // If this was previously a closed state, and it has been knoced back to
1581 // graded, then fix up the state again.
1582 if ($replaystate->event == QUESTION_EVENTGRADE &&
1583 ($states[$j]->event == QUESTION_EVENTCLOSE ||
1584 $states[$j]->event == QUESTION_EVENTCLOSEANDGRADE)) {
1585 $replaystate->event = $states[$j]->event;
1589 $replaystate->id = $states[$j]->id;
1590 $replaystate->changed = true;
1591 $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
1592 if (!$dryrun){
1593 save_question_session($question, $replaystate);
1596 if ($changed) {
1597 if (!$dryrun){
1598 // TODO, call a method in quiz to do this, where 'quiz' comes from
1599 // the question_attempts table.
1600 $DB->update_record('quiz_attempts', $attempt);
1603 if ($changed){
1604 $toinsert = new object();
1605 $toinsert->oldgrade = round((float)$states[count($states)-1]->grade, 5);
1606 $toinsert->newgrade = round((float)$replaystate->grade, 5);
1607 $toinsert->attemptid = $attempt->uniqueid;
1608 $toinsert->questionid = $question->id;
1609 //the grade saved is the old grade if the new grade is saved
1610 //it is the new grade if this is a dry run.
1611 $toinsert->regraded = $dryrun?0:1;
1612 $toinsert->timemodified = time();
1613 $DB->insert_record('quiz_question_regrade', $toinsert);
1614 return true;
1615 } else {
1616 return false;
1619 return false;
1623 * Processes an array of student responses, grading and saving them as appropriate
1625 * @global array
1626 * @param object $question Full question object, passed by reference
1627 * @param object $state Full state object, passed by reference
1628 * @param object $action object with the fields ->responses which
1629 * is an array holding the student responses,
1630 * ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
1631 * and ->timestamp which is a timestamp from when the responses
1632 * were submitted by the student.
1633 * @param object $cmoptions
1634 * @param object $attempt The attempt is passed by reference so that
1635 * during grading its ->sumgrades field can be updated
1636 * @return boolean Indicates success/failure
1638 function question_process_responses($question, &$state, $action, $cmoptions, &$attempt) {
1639 global $QTYPES;
1641 // if no responses are set initialise to empty response
1642 if (!isset($action->responses)) {
1643 $action->responses = array('' => '');
1646 $state->newflaggedstate = !empty($action->responses['_flagged']);
1648 // make sure these are gone!
1649 unset($action->responses['submit'], $action->responses['validate'], $action->responses['_flagged']);
1651 // Check the question session is still open
1652 if (question_state_is_closed($state)) {
1653 return true;
1656 // If $action->event is not set that implies saving
1657 if (! isset($action->event)) {
1658 debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER);
1659 $action->event = QUESTION_EVENTSAVE;
1661 // If submitted then compare against last graded
1662 // responses, not last given responses in this case
1663 if (question_isgradingevent($action->event)) {
1664 $state->responses = $state->last_graded->responses;
1667 // Check for unchanged responses (exactly unchanged, not equivalent).
1668 // We also have to catch questions that the student has not yet attempted
1669 $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
1670 if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN &&
1671 question_isgradingevent($action->event)) {
1672 $sameresponses = false;
1675 // If the response has not been changed then we do not have to process it again
1676 // unless the attempt is closing or validation is requested
1677 if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
1678 and QUESTION_EVENTVALIDATE != $action->event) {
1679 return true;
1682 // Roll back grading information to last graded state and set the new
1683 // responses
1684 $newstate = clone($state->last_graded);
1685 $newstate->responses = $action->responses;
1686 $newstate->seq_number = $state->seq_number + 1;
1687 $newstate->changed = true; // will assure that it gets saved to the database
1688 $newstate->last_graded = clone($state->last_graded);
1689 $newstate->timestamp = $action->timestamp;
1690 $newstate->newflaggedstate = $state->newflaggedstate;
1691 $newstate->flagged = $state->flagged;
1692 $newstate->questionsessionid = $state->questionsessionid;
1693 $state = $newstate;
1695 // Set the event to the action we will perform. The question type specific
1696 // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
1697 // attempt at the question causes the session to close
1698 $state->event = $action->event;
1700 if (!question_isgradingevent($action->event)) {
1701 // Grade the response but don't update the overall grade
1702 if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
1703 return false;
1706 // Temporary hack because question types are not given enough control over what is going
1707 // on. Used by Opaque questions.
1708 // TODO fix this code properly.
1709 if (!empty($state->believeevent)) {
1710 // If the state was graded we need to ...
1711 if (question_state_is_graded($state)) {
1712 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1714 // update the attempt grade
1715 $attempt->sumgrades -= (float)$state->last_graded->grade;
1716 $attempt->sumgrades += (float)$state->grade;
1718 // and update the last_graded field.
1719 unset($state->last_graded);
1720 $state->last_graded = clone($state);
1721 unset($state->last_graded->changed);
1723 } else {
1724 // Don't allow the processing to change the event type
1725 $state->event = $action->event;
1728 } else { // grading event
1730 // Unless the attempt is closing, we want to work out if the current responses
1731 // (or equivalent responses) were already given in the last graded attempt.
1732 if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
1733 $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
1734 $state->event = QUESTION_EVENTDUPLICATE;
1737 // If we did not find a duplicate or if the attempt is closing, perform grading
1738 if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
1739 QUESTION_EVENTCLOSE == $action->event) {
1740 if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
1741 return false;
1744 // Calculate overall grade using correct penalty method
1745 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1748 // If the state was graded we need to ...
1749 if (question_state_is_graded($state)) {
1750 // update the attempt grade
1751 $attempt->sumgrades -= (float)$state->last_graded->grade;
1752 $attempt->sumgrades += (float)$state->grade;
1754 // and update the last_graded field.
1755 unset($state->last_graded);
1756 $state->last_graded = clone($state);
1757 unset($state->last_graded->changed);
1760 $attempt->timemodified = $action->timestamp;
1762 return true;
1766 * Determine if event requires grading
1768 function question_isgradingevent($event) {
1769 return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
1773 * Applies the penalty from the previous graded responses to the raw grade
1774 * for the current responses
1776 * The grade for the question in the current state is computed by subtracting the
1777 * penalty accumulated over the previous graded responses at the question from the
1778 * raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
1779 * the grade is set to zero. The ->grade field of the state object is modified to
1780 * reflect the new grade but is never allowed to decrease.
1781 * @param object $question The question for which the penalty is to be applied.
1782 * @param object $state The state for which the grade is to be set from the
1783 * raw grade and the cumulative penalty from the last
1784 * graded state. The ->grade field is updated by applying
1785 * the penalty scheme determined in $cmoptions to the ->raw_grade and
1786 * ->last_graded->penalty fields.
1787 * @param object $cmoptions The options set by the course module.
1788 * The ->penaltyscheme field determines whether penalties
1789 * for incorrect earlier responses are subtracted.
1791 function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
1792 // TODO. Quiz dependancy. The fact that the attempt that is passed in here
1793 // is from quiz_attempts, and we use things like $cmoptions->timelimit.
1795 // deal with penalty
1796 if ($cmoptions->penaltyscheme) {
1797 $state->grade = $state->raw_grade - $state->sumpenalty;
1798 $state->sumpenalty += (float) $state->penalty;
1799 } else {
1800 $state->grade = $state->raw_grade;
1803 // deal with timelimit
1804 if ($cmoptions->timelimit) {
1805 // We allow for 5% uncertainty in the following test
1806 if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 1.05) {
1807 $cm = get_coursemodule_from_instance('quiz', $cmoptions->id);
1808 if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id),
1809 $attempt->userid, false)) {
1810 $state->grade = 0;
1815 // deal with closing time
1816 if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
1817 and !$attempt->preview) { // ignore closing time for previews
1818 $state->grade = 0;
1821 // Ensure that the grade does not go down
1822 $state->grade = max($state->grade, $state->last_graded->grade);
1826 * Print the icon for the question type
1828 * @global array
1829 * @global object
1830 * @param object $question The question object for which the icon is required
1831 * only $question->qtype is used.
1832 * @param boolean $return If true the functions returns the link as a string
1834 function print_question_icon($question, $return = false) {
1835 global $QTYPES, $CFG, $OUTPUT;
1837 if (array_key_exists($question->qtype, $QTYPES)) {
1838 $namestr = $QTYPES[$question->qtype]->local_name();
1839 } else {
1840 $namestr = 'missingtype';
1842 $html = '<img src="' . $OUTPUT->pix_url('icon', 'qtype_'.$question->qtype) . '" alt="' .
1843 $namestr . '" title="' . $namestr . '" />';
1844 if ($return) {
1845 return $html;
1846 } else {
1847 echo $html;
1852 * Returns a html link to the question image if there is one
1854 * @global object
1855 * @global object
1856 * @return string The html image tag or the empy string if there is no image.
1857 * @param object $question The question object
1859 function get_question_image($question) {
1860 global $CFG, $DB;
1861 $img = '';
1863 if (!$category = $DB->get_record('question_categories', array('id'=>$question->category))) {
1864 print_error('invalidcategory');
1866 $coursefilesdir = get_filesdir_from_context(get_context_instance_by_id($category->contextid));
1868 if ($question->image) {
1870 if (substr(strtolower($question->image), 0, 7) == 'http://') {
1871 $img .= $question->image;
1873 } else {
1874 require_once($CFG->libdir .'/filelib.php');
1875 $img = get_file_url("$coursefilesdir/{$question->image}");
1878 return $img;
1882 * @global array
1884 function question_print_comment_fields($question, $state, $prefix, $cmoptions, $caption = '') {
1885 global $QTYPES;
1886 $idprefix = preg_replace('/[^-_a-zA-Z0-9]/', '', $prefix);
1887 $otherquestionsinuse = '';
1888 if (!empty($cmoptions->questions)) {
1889 $otherquestionsinuse = $cmoptions->questions;
1891 if (!question_state_is_graded($state) && $QTYPES[$question->qtype]->is_question_manual_graded($question, $otherquestionsinuse)) {
1892 $grade = '';
1893 } else {
1894 $grade = question_format_grade($cmoptions, $state->last_graded->grade);
1896 $maxgrade = question_format_grade($cmoptions, $question->maxgrade);
1897 $fieldsize = strlen($maxgrade) - 1;
1898 if (empty($caption)) {
1899 $caption = format_string($question->name);
1902 <fieldset class="que comment clearfix">
1903 <legend class="ftoggler"><?php echo $caption; ?></legend>
1904 <div class="fcontainer clearfix">
1905 <div class="fitem">
1906 <div class="fitemtitle">
1907 <label for="<?php echo $idprefix; ?>_comment_box"><?php print_string('comment', 'quiz'); ?></label>
1908 </div>
1909 <div class="felement fhtmleditor">
1910 <?php print_textarea(can_use_html_editor(), 15, 60, 630, 300, $prefix . '[comment]',
1911 $state->manualcomment, 0, false, $idprefix . '_comment_box'); ?>
1912 </div>
1913 </div>
1914 <div class="fitem">
1915 <div class="fitemtitle">
1916 <label for="<?php echo $idprefix; ?>_grade_field"><?php print_string('grade', 'quiz'); ?></label>
1917 </div>
1918 <div class="felement ftext">
1919 <input type="text" name="<?php echo $prefix; ?>[grade]" size="<?php echo $fieldsize; ?>" id="<?php echo $idprefix; ?>_grade_field" value="<?php echo $grade; ?>" /> / <?php echo $maxgrade; ?>
1920 </div>
1921 </div>
1922 </div>
1923 </fieldset>
1924 <?php
1928 * Process a manual grading action. That is, use $comment and $grade to update
1929 * $state and $attempt. The attempt and the comment text are stored in the
1930 * database. $state is only updated in memory, it is up to the call to store
1931 * that, if appropriate.
1933 * @global object
1934 * @param object $question the question
1935 * @param object $state the state to be updated.
1936 * @param object $attempt the attempt the state belongs to, to be updated.
1937 * @param string $comment the new comment from the teacher.
1938 * @param mixed $grade the grade the teacher assigned, or '' to not change the grade.
1939 * @return mixed true on success, a string error message if a problem is detected
1940 * (for example score out of range).
1942 function question_process_comment($question, &$state, &$attempt, $comment, $grade) {
1943 global $DB;
1945 $grade = trim($grade);
1946 if ($grade < 0 || $grade > $question->maxgrade) {
1947 $a = new stdClass;
1948 $a->grade = $grade;
1949 $a->maxgrade = $question->maxgrade;
1950 $a->name = $question->name;
1951 return get_string('errormanualgradeoutofrange', 'question', $a);
1954 // Update the comment and save it in the database
1955 $comment = trim($comment);
1956 $state->manualcomment = $comment;
1957 $state->newflaggedstate = $state->flagged;
1958 $DB->set_field('question_sessions', 'manualcomment', $comment, array('attemptid'=>$attempt->uniqueid, 'questionid'=>$question->id));
1960 // Update the attempt if the score has changed.
1961 if ($grade !== '' && (abs($state->last_graded->grade - $grade) > 0.002 || $state->last_graded->event != QUESTION_EVENTMANUALGRADE)) {
1962 $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
1963 $attempt->timemodified = time();
1964 $DB->update_record('quiz_attempts', $attempt);
1966 // We want to update existing state (rather than creating new one) if it
1967 // was itself created by a manual grading event.
1968 $state->update = $state->event == QUESTION_EVENTMANUALGRADE;
1970 // Update the other parts of the state object.
1971 $state->raw_grade = $grade;
1972 $state->grade = $grade;
1973 $state->penalty = 0;
1974 $state->timestamp = time();
1975 $state->seq_number++;
1976 $state->event = QUESTION_EVENTMANUALGRADE;
1978 // Update the last graded state (don't simplify!)
1979 unset($state->last_graded);
1980 $state->last_graded = clone($state);
1982 // We need to indicate that the state has changed in order for it to be saved.
1983 $state->changed = 1;
1986 return true;
1990 * Construct name prefixes for question form element names
1992 * Construct the name prefix that should be used for example in the
1993 * names of form elements created by questions.
1994 * This is called by {@link get_question_options()}
1995 * to set $question->name_prefix.
1996 * This name prefix includes the question id which can be
1997 * extracted from it with {@link question_get_id_from_name_prefix()}.
1999 * @return string
2000 * @param integer $id The question id
2002 function question_make_name_prefix($id) {
2003 return 'resp' . $id . '_';
2007 * Extract question id from the prefix of form element names
2009 * @return integer The question id
2010 * @param string $name The name that contains a prefix that was
2011 * constructed with {@link question_make_name_prefix()}
2013 function question_get_id_from_name_prefix($name) {
2014 if (!preg_match('/^resp([0-9]+)_/', $name, $matches)) {
2015 return false;
2017 return (integer) $matches[1];
2021 * Extract question id from the prefix of form element names
2023 * @return integer The question id
2024 * @param string $name The name that contains a prefix that was
2025 * constructed with {@link question_make_name_prefix()}
2027 function question_id_and_key_from_post_name($name) {
2028 if (!preg_match('/^resp([0-9]+)_(.*)$/', $name, $matches)) {
2029 return array(false, false);
2031 return array((integer) $matches[1], $matches[2]);
2035 * Returns the unique id for a new attempt
2037 * Every module can keep their own attempts table with their own sequential ids but
2038 * the question code needs to also have a unique id by which to identify all these
2039 * attempts. Hence a module, when creating a new attempt, calls this function and
2040 * stores the return value in the 'uniqueid' field of its attempts table.
2042 * @global object
2044 function question_new_attempt_uniqueid($modulename='quiz') {
2045 global $DB;
2047 $attempt = new stdClass;
2048 $attempt->modulename = $modulename;
2049 $id = $DB->insert_record('question_attempts', $attempt);
2050 return $id;
2054 * Creates a stamp that uniquely identifies this version of the question
2056 * In future we want this to use a hash of the question data to guarantee that
2057 * identical versions have the same version stamp.
2059 * @param object $question
2060 * @return string A unique version stamp
2062 function question_hash($question) {
2063 return make_unique_id_code();
2067 * Round a grade to to the correct number of decimal places, and format it for display.
2068 * If $cmoptions->questiondecimalpoints is set, that is used, otherwise
2069 * else if $cmoptions->decimalpoints is used,
2070 * otherwise a default of 2 is used, but this should not be relied upon, and generated a developer debug warning.
2071 * However, if $cmoptions->questiondecimalpoints is -1, the means use $cmoptions->decimalpoints.
2073 * @param object $cmoptions The modules settings.
2074 * @param float $grade The grade to round.
2076 function question_format_grade($cmoptions, $grade) {
2077 if (isset($cmoptions->questiondecimalpoints) && $cmoptions->questiondecimalpoints != -1) {
2078 $decimalplaces = $cmoptions->questiondecimalpoints;
2079 } else if (isset($cmoptions->decimalpoints)) {
2080 $decimalplaces = $cmoptions->decimalpoints;
2081 } else {
2082 $decimalplaces = 2;
2083 debugging('Code that leads to question_format_grade being called should set ' .
2084 '$cmoptions->questiondecimalpoints or $cmoptions->decimalpoints', DEBUG_DEVELOPER);
2086 return format_float($grade, $decimalplaces);
2090 * @return string An inline script that creates a JavaScript object storing
2091 * various strings and bits of configuration that the scripts in qengine.js need
2092 * to get from PHP.
2094 function question_init_qengine_js() {
2095 global $CFG, $PAGE, $OUTPUT;
2096 static $done = false;
2097 if ($done) {
2098 return;
2100 $module = array(
2101 'name' => 'core_question_flags',
2102 'fullpath' => '/question/flags.js',
2103 'requires' => array('base', 'dom', 'event-delegate', 'io-base'),
2105 $actionurl = $CFG->wwwroot . '/question/toggleflag.php';
2106 $flagattributes = array(
2107 0 => array(
2108 'src' => $OUTPUT->pix_url('i/unflagged') . '',
2109 'title' => get_string('clicktoflag', 'question'),
2110 'alt' => get_string('notflagged', 'question'),
2112 1 => array(
2113 'src' => $OUTPUT->pix_url('i/flagged') . '',
2114 'title' => get_string('clicktounflag', 'question'),
2115 'alt' => get_string('flagged', 'question'),
2118 $PAGE->requires->js_init_call('M.core_question_flags.init',
2119 array($actionurl, $flagattributes), false, $module);
2120 $done = true;
2123 /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
2125 * Give the questions in $questionlist a chance to request the CSS or JavaScript
2126 * they need, before the header is printed.
2128 * If your code is going to call the print_question function, it must call this
2129 * funciton before print_header.
2131 * @param array $questionlist a list of questionids of the questions what will appear on this page.
2132 * @param array $questions an array of question objects, whose keys are question ids.
2133 * Must contain all the questions in $questionlist
2134 * @param array $states an array of question state objects, whose keys are question ids.
2135 * Must contain the state of all the questions in $questionlist
2137 function question_get_html_head_contributions($questionlist, &$questions, &$states) {
2138 global $CFG, $PAGE, $QTYPES;
2140 // The question engine's own JavaScript.
2141 question_init_qengine_js();
2143 // Anything that questions on this page need.
2144 foreach ($questionlist as $questionid) {
2145 $question = $questions[$questionid];
2146 $QTYPES[$question->qtype]->get_html_head_contributions($question, $states[$questionid]);
2151 * Like {@link get_html_head_contributions()} but for the editing page
2152 * question/question.php.
2154 * @param $question A question object. Only $question->qtype is used.
2155 * @return string Deprecated. Some HTML code that can go inside the head tag.
2157 function question_get_editing_head_contributions($question) {
2158 global $QTYPES;
2159 $QTYPES[$question->qtype]->get_editing_head_contributions();
2163 * Prints a question
2165 * Simply calls the question type specific print_question() method.
2167 * @global array
2168 * @param object $question The question to be rendered.
2169 * @param object $state The state to render the question in.
2170 * @param integer $number The number for this question.
2171 * @param object $cmoptions The options specified by the course module
2172 * @param object $options An object specifying the rendering options.
2174 function print_question(&$question, &$state, $number, $cmoptions, $options=null, $context=null) {
2175 global $QTYPES;
2176 $QTYPES[$question->qtype]->print_question($question, $state, $number, $cmoptions, $options, $context);
2179 * Saves question options
2181 * Simply calls the question type specific save_question_options() method.
2183 * @global array
2185 function save_question_options($question) {
2186 global $QTYPES;
2188 $QTYPES[$question->qtype]->save_question_options($question);
2192 * Gets all teacher stored answers for a given question
2194 * Simply calls the question type specific get_all_responses() method.
2196 * @global array
2198 // ULPGC ecastro
2199 function get_question_responses($question, $state) {
2200 global $QTYPES;
2201 $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
2202 return $r;
2206 * Gets the response given by the user in a particular state
2208 * Simply calls the question type specific get_actual_response() method.
2210 * @global array
2212 // ULPGC ecastro
2213 function get_question_actual_response($question, $state) {
2214 global $QTYPES;
2216 $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
2217 return $r;
2221 * TODO: document this
2223 * @global array
2225 // ULPGc ecastro
2226 function get_question_fraction_grade($question, $state) {
2227 global $QTYPES;
2229 $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
2230 return $r;
2233 * @global array
2234 * @return integer grade out of 1 that a random guess by a student might score.
2236 // ULPGc ecastro
2237 function question_get_random_guess_score($question) {
2238 global $QTYPES;
2240 $r = $QTYPES[$question->qtype]->get_random_guess_score($question);
2241 return $r;
2243 /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
2246 * returns the categories with their names ordered following parent-child relationships
2247 * finally it tries to return pending categories (those being orphaned, whose parent is
2248 * incorrect) to avoid missing any category from original array.
2250 * @global object
2252 function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
2253 global $DB;
2255 $children = array();
2256 $keys = array_keys($categories);
2258 foreach ($keys as $key) {
2259 if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
2260 $children[$key] = $categories[$key];
2261 $categories[$key]->processed = true;
2262 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
2265 //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
2266 if ($level == 1) {
2267 foreach ($keys as $key) {
2268 //If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
2269 if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories', array('course'=>$categories[$key]->course, 'id'=>$categories[$key]->parent))) {
2270 $children[$key] = $categories[$key];
2271 $categories[$key]->processed = true;
2272 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
2276 return $children;
2280 * Private method, only for the use of add_indented_names().
2282 * Recursively adds an indentedname field to each category, starting with the category
2283 * with id $id, and dealing with that category and all its children, and
2284 * return a new array, with those categories in the right order.
2286 * @param array $categories an array of categories which has had childids
2287 * fields added by flatten_category_tree(). Passed by reference for
2288 * performance only. It is not modfied.
2289 * @param int $id the category to start the indenting process from.
2290 * @param int $depth the indent depth. Used in recursive calls.
2291 * @return array a new array of categories, in the right order for the tree.
2293 function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
2295 // Indent the name of this category.
2296 $newcategories = array();
2297 $newcategories[$id] = $categories[$id];
2298 $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
2300 // Recursively indent the children.
2301 foreach ($categories[$id]->childids as $childid) {
2302 if ($childid != $nochildrenof){
2303 $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1, $nochildrenof);
2307 // Remove the childids array that were temporarily added.
2308 unset($newcategories[$id]->childids);
2310 return $newcategories;
2314 * Format categories into an indented list reflecting the tree structure.
2316 * @param array $categories An array of category objects, for example from the.
2317 * @return array The formatted list of categories.
2319 function add_indented_names($categories, $nochildrenof = -1) {
2321 // Add an array to each category to hold the child category ids. This array will be removed
2322 // again by flatten_category_tree(). It should not be used outside these two functions.
2323 foreach (array_keys($categories) as $id) {
2324 $categories[$id]->childids = array();
2327 // Build the tree structure, and record which categories are top-level.
2328 // We have to be careful, because the categories array may include published
2329 // categories from other courses, but not their parents.
2330 $toplevelcategoryids = array();
2331 foreach (array_keys($categories) as $id) {
2332 if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
2333 $categories[$categories[$id]->parent]->childids[] = $id;
2334 } else {
2335 $toplevelcategoryids[] = $id;
2339 // Flatten the tree to and add the indents.
2340 $newcategories = array();
2341 foreach ($toplevelcategoryids as $id) {
2342 $newcategories = $newcategories + flatten_category_tree($categories, $id, 0, $nochildrenof);
2345 return $newcategories;
2349 * Output a select menu of question categories.
2351 * Categories from this course and (optionally) published categories from other courses
2352 * are included. Optionally, only categories the current user may edit can be included.
2354 * @param integer $courseid the id of the course to get the categories for.
2355 * @param integer $published if true, include publised categories from other courses.
2356 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
2357 * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
2359 function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) {
2360 global $OUTPUT;
2361 $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof);
2362 if ($selected) {
2363 $choose = '';
2364 } else {
2365 $choose = 'choosedots';
2367 $options = array();
2368 foreach($categoriesarray as $group=>$opts) {
2369 $options[] = array($group=>$opts);
2372 echo html_writer::select($options, 'category', $selected, $choose);
2376 * @global object
2377 * @param integer $contextid a context id.
2378 * @return object the default question category for that context, or false if none.
2380 function question_get_default_category($contextid) {
2381 global $DB;
2382 $category = $DB->get_records('question_categories', array('contextid' => $contextid),'id','*',0,1);
2383 if (!empty($category)) {
2384 return reset($category);
2385 } else {
2386 return false;
2391 * @global object
2392 * @global object
2393 * @param object $context a context
2394 * @return string A URL for editing questions in this context.
2396 function question_edit_url($context) {
2397 global $CFG, $SITE;
2398 if (!has_any_capability(question_get_question_capabilities(), $context)) {
2399 return false;
2401 $baseurl = $CFG->wwwroot . '/question/edit.php?';
2402 $defaultcategory = question_get_default_category($context->id);
2403 if ($defaultcategory) {
2404 $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&amp;';
2406 switch ($context->contextlevel) {
2407 case CONTEXT_SYSTEM:
2408 return $baseurl . 'courseid=' . $SITE->id;
2409 case CONTEXT_COURSECAT:
2410 // This is nasty, becuase we can only edit questions in a course
2411 // context at the moment, so for now we just return false.
2412 return false;
2413 case CONTEXT_COURSE:
2414 return $baseurl . 'courseid=' . $context->instanceid;
2415 case CONTEXT_MODULE:
2416 return $baseurl . 'cmid=' . $context->instanceid;
2422 * Gets the default category in the most specific context.
2423 * If no categories exist yet then default ones are created in all contexts.
2425 * @global object
2426 * @param array $contexts The context objects for this context and all parent contexts.
2427 * @return object The default category - the category in the course context
2429 function question_make_default_categories($contexts) {
2430 global $DB;
2431 static $preferredlevels = array(
2432 CONTEXT_COURSE => 4,
2433 CONTEXT_MODULE => 3,
2434 CONTEXT_COURSECAT => 2,
2435 CONTEXT_SYSTEM => 1,
2438 $toreturn = null;
2439 $preferredness = 0;
2440 // If it already exists, just return it.
2441 foreach ($contexts as $key => $context) {
2442 if (!$exists = $DB->record_exists("question_categories", array('contextid'=>$context->id))) {
2443 // Otherwise, we need to make one
2444 $category = new stdClass;
2445 $contextname = print_context_name($context, false, true);
2446 $category->name = get_string('defaultfor', 'question', $contextname);
2447 $category->info = get_string('defaultinfofor', 'question', $contextname);
2448 $category->contextid = $context->id;
2449 $category->parent = 0;
2450 $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically.
2451 $category->stamp = make_unique_id_code();
2452 $category->id = $DB->insert_record('question_categories', $category);
2453 } else {
2454 $category = question_get_default_category($context->id);
2456 if ($preferredlevels[$context->contextlevel] > $preferredness &&
2457 has_any_capability(array('moodle/question:usemine', 'moodle/question:useall'), $context)) {
2458 $toreturn = $category;
2459 $preferredness = $preferredlevels[$context->contextlevel];
2463 if (!is_null($toreturn)) {
2464 $toreturn = clone($toreturn);
2466 return $toreturn;
2470 * Get all the category objects, including a count of the number of questions in that category,
2471 * for all the categories in the lists $contexts.
2473 * @global object
2474 * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
2475 * @param string $sortorder used as the ORDER BY clause in the select statement.
2476 * @return array of category objects.
2478 function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
2479 global $DB;
2480 return $DB->get_records_sql("
2481 SELECT c.*, (SELECT count(1) FROM {question} q
2482 WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
2483 FROM {question_categories} c
2484 WHERE c.contextid IN ($contexts)
2485 ORDER BY $sortorder");
2489 * Output an array of question categories.
2490 * @global object
2492 function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) {
2493 global $CFG;
2494 $pcontexts = array();
2495 foreach($contexts as $context){
2496 $pcontexts[] = $context->id;
2498 $contextslist = join($pcontexts, ', ');
2500 $categories = get_categories_for_contexts($contextslist);
2502 $categories = question_add_context_in_key($categories);
2504 if ($top){
2505 $categories = question_add_tops($categories, $pcontexts);
2507 $categories = add_indented_names($categories, $nochildrenof);
2509 //sort cats out into different contexts
2510 $categoriesarray = array();
2511 foreach ($pcontexts as $pcontext){
2512 $contextstring = print_context_name(get_context_instance_by_id($pcontext), true, true);
2513 foreach ($categories as $category) {
2514 if ($category->contextid == $pcontext){
2515 $cid = $category->id;
2516 if ($currentcat!= $cid || $currentcat==0) {
2517 $countstring = (!empty($category->questioncount))?" ($category->questioncount)":'';
2518 $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
2523 if ($popupform){
2524 $popupcats = array();
2525 foreach ($categoriesarray as $contextstring => $optgroup){
2526 $group = array();
2527 foreach ($optgroup as $key=>$value) {
2528 $key = str_replace($CFG->wwwroot, '', $key);
2529 $group[$key] = $value;
2531 $popupcats[] = array($contextstring=>$group);
2533 return $popupcats;
2534 } else {
2535 return $categoriesarray;
2539 function question_add_context_in_key($categories){
2540 $newcatarray = array();
2541 foreach ($categories as $id => $category) {
2542 $category->parent = "$category->parent,$category->contextid";
2543 $category->id = "$category->id,$category->contextid";
2544 $newcatarray["$id,$category->contextid"] = $category;
2546 return $newcatarray;
2548 function question_add_tops($categories, $pcontexts){
2549 $topcats = array();
2550 foreach ($pcontexts as $context){
2551 $newcat = new object();
2552 $newcat->id = "0,$context";
2553 $newcat->name = get_string('top');
2554 $newcat->parent = -1;
2555 $newcat->contextid = $context;
2556 $topcats["0,$context"] = $newcat;
2558 //put topcats in at beginning of array - they'll be sorted into different contexts later.
2559 return array_merge($topcats, $categories);
2563 * Returns a comma separated list of ids of the category and all subcategories
2564 * @global object
2566 function question_categorylist($categoryid) {
2567 global $DB;
2569 // returns a comma separated list of ids of the category and all subcategories
2570 $categorylist = $categoryid;
2571 if ($subcategories = $DB->get_records('question_categories', array('parent'=>$categoryid), 'sortorder ASC', 'id, 1')) {
2572 foreach ($subcategories as $subcategory) {
2573 $categorylist .= ','. question_categorylist($subcategory->id);
2576 return $categorylist;
2582 //===========================
2583 // Import/Export Functions
2584 //===========================
2587 * Get list of available import or export formats
2589 * @global object
2590 * @param string $type 'import' if import list, otherwise export list assumed
2591 * @return array sorted list of import/export formats available
2593 function get_import_export_formats( $type ) {
2595 global $CFG;
2596 $fileformats = get_plugin_list("qformat");
2598 $fileformatname=array();
2599 require_once( "{$CFG->dirroot}/question/format.php" );
2600 foreach ($fileformats as $fileformat=>$fdir) {
2601 $format_file = "$fdir/format.php";
2602 if (file_exists($format_file) ) {
2603 require_once($format_file);
2605 else {
2606 continue;
2608 $classname = "qformat_$fileformat";
2609 $format_class = new $classname();
2610 if ($type=='import') {
2611 $provided = $format_class->provide_import();
2613 else {
2614 $provided = $format_class->provide_export();
2616 if ($provided) {
2617 $formatname = get_string($fileformat, 'quiz');
2618 if ($formatname == "[[$fileformat]]") {
2619 $formatname = get_string($fileformat, 'qformat_'.$fileformat);
2620 if ($formatname == "[[$fileformat]]") {
2621 $formatname = $fileformat; // Just use the raw folder name
2624 $fileformatnames[$fileformat] = $formatname;
2627 natcasesort($fileformatnames);
2629 return $fileformatnames;
2634 * Create default export filename
2636 * @return string default export filename
2637 * @param object $course
2638 * @param object $category
2640 function default_export_filename($course,$category) {
2641 //Take off some characters in the filename !!
2642 $takeoff = array(" ", ":", "/", "\\", "|");
2643 $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz")));
2644 //If non-translated, use "export"
2645 if (substr($export_word,0,1) == "[") {
2646 $export_word= "export";
2649 //Calculate the date format string
2650 $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz"));
2651 //If non-translated, use "%Y%m%d-%H%M"
2652 if (substr($export_date_format,0,1) == "[") {
2653 $export_date_format = "%%Y%%m%%d-%%H%%M";
2656 //Calculate the shortname
2657 $export_shortname = clean_filename($course->shortname);
2658 if (empty($export_shortname) or $export_shortname == '_' ) {
2659 $export_shortname = $course->id;
2662 //Calculate the category name
2663 $export_categoryname = clean_filename($category->name);
2665 //Calculate the final export filename
2666 //The export word
2667 $export_name = $export_word."-";
2668 //The shortname
2669 $export_name .= moodle_strtolower($export_shortname)."-";
2670 //The category name
2671 $export_name .= moodle_strtolower($export_categoryname)."-";
2672 //The date format
2673 $export_name .= userdate(time(),$export_date_format,99,false);
2674 //Extension is supplied by format later.
2676 return $export_name;
2680 * @package moodlecore
2681 * @subpackage question
2682 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
2683 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2685 class context_to_string_translator{
2687 * @var array used to translate between contextids and strings for this context.
2689 var $contexttostringarray = array();
2691 function context_to_string_translator($contexts){
2692 $this->generate_context_to_string_array($contexts);
2695 function context_to_string($contextid){
2696 return $this->contexttostringarray[$contextid];
2699 function string_to_context($contextname){
2700 $contextid = array_search($contextname, $this->contexttostringarray);
2701 return $contextid;
2704 function generate_context_to_string_array($contexts){
2705 if (!$this->contexttostringarray){
2706 $catno = 1;
2707 foreach ($contexts as $context){
2708 switch ($context->contextlevel){
2709 case CONTEXT_MODULE :
2710 $contextstring = 'module';
2711 break;
2712 case CONTEXT_COURSE :
2713 $contextstring = 'course';
2714 break;
2715 case CONTEXT_COURSECAT :
2716 $contextstring = "cat$catno";
2717 $catno++;
2718 break;
2719 case CONTEXT_SYSTEM :
2720 $contextstring = 'system';
2721 break;
2723 $this->contexttostringarray[$context->id] = $contextstring;
2731 * @return array all the capabilities that relate to accessing particular questions.
2733 function question_get_question_capabilities() {
2734 return array(
2735 'moodle/question:add',
2736 'moodle/question:editmine',
2737 'moodle/question:editall',
2738 'moodle/question:viewmine',
2739 'moodle/question:viewall',
2740 'moodle/question:usemine',
2741 'moodle/question:useall',
2742 'moodle/question:movemine',
2743 'moodle/question:moveall',
2748 * @return array all the question bank capabilities.
2750 function question_get_all_capabilities() {
2751 $caps = question_get_question_capabilities();
2752 $caps[] = 'moodle/question:managecategory';
2753 $caps[] = 'moodle/question:flag';
2754 return $caps;
2758 * Check capability on category
2760 * @global object
2761 * @global object
2762 * @param mixed $question object or id
2763 * @param string $cap 'add', 'edit', 'view', 'use', 'move'
2764 * @param integer $cachecat useful to cache all question records in a category
2765 * @return boolean this user has the capability $cap for this question $question?
2767 function question_has_capability_on($question, $cap, $cachecat = -1){
2768 global $USER, $DB;
2770 // nicolasconnault@gmail.com In some cases I get $question === false. Since no such object exists, it can't be deleted, we can safely return true
2771 if ($question === false) {
2772 return true;
2775 // these are capabilities on existing questions capabilties are
2776 //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
2777 $question_questioncaps = array('edit', 'view', 'use', 'move');
2778 static $questions = array();
2779 static $categories = array();
2780 static $cachedcat = array();
2781 if ($cachecat != -1 && (array_search($cachecat, $cachedcat)===FALSE)){
2782 $questions += $DB->get_records('question', array('category'=>$cachecat));
2783 $cachedcat[] = $cachecat;
2785 if (!is_object($question)){
2786 if (!isset($questions[$question])){
2787 if (!$questions[$question] = $DB->get_record('question', array('id'=>$question), 'id,category,createdby')) {
2788 print_error('questiondoesnotexist', 'question');
2791 $question = $questions[$question];
2793 if (!isset($categories[$question->category])){
2794 if (!$categories[$question->category] = $DB->get_record('question_categories', array('id'=>$question->category))) {
2795 print_error('invalidcategory', 'quiz');
2798 $category = $categories[$question->category];
2800 if (array_search($cap, $question_questioncaps)!== FALSE){
2801 if (!has_capability('moodle/question:'.$cap.'all', get_context_instance_by_id($category->contextid))){
2802 if ($question->createdby == $USER->id){
2803 return has_capability('moodle/question:'.$cap.'mine', get_context_instance_by_id($category->contextid));
2804 } else {
2805 return false;
2807 } else {
2808 return true;
2810 } else {
2811 return has_capability('moodle/question:'.$cap, get_context_instance_by_id($category->contextid));
2817 * Require capability on question.
2819 function question_require_capability_on($question, $cap){
2820 if (!question_has_capability_on($question, $cap)){
2821 print_error('nopermissions', '', '', $cap);
2823 return true;
2827 * @global object
2829 function question_file_links_base_url($courseid){
2830 global $CFG;
2831 $baseurl = preg_quote("$CFG->wwwroot/file.php", '!');
2832 $baseurl .= '('.preg_quote('?file=', '!').')?';//may or may not
2833 //be using slasharguments, accept either
2834 $baseurl .= "/$courseid/";//course directory
2835 return $baseurl;
2839 * Find all course / site files linked to in a piece of html.
2840 * @global object
2841 * @param string html the html to search
2842 * @param int course search for files for courseid course or set to siteid for
2843 * finding site files.
2844 * @return array files with keys being files.
2846 function question_find_file_links_from_html($html, $courseid){
2847 global $CFG;
2848 $baseurl = question_file_links_base_url($courseid);
2849 $searchfor = '!'.
2850 '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$baseurl.'([^"]*)"'.
2851 '|'.
2852 '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$baseurl.'([^\']*)\''.
2853 '!i';
2854 $matches = array();
2855 $no = preg_match_all($searchfor, $html, $matches);
2856 if ($no){
2857 $rawurls = array_filter(array_merge($matches[5], $matches[10]));//array_filter removes empty elements
2858 //remove any links that point somewhere they shouldn't
2859 foreach (array_keys($rawurls) as $rawurlkey){
2860 if (!$cleanedurl = question_url_check($rawurls[$rawurlkey])){
2861 unset($rawurls[$rawurlkey]);
2862 } else {
2863 $rawurls[$rawurlkey] = $cleanedurl;
2867 $urls = array_flip($rawurls);// array_flip removes duplicate files
2868 // and when we merge arrays will continue to automatically remove duplicates
2869 } else {
2870 $urls = array();
2872 return $urls;
2876 * Check that url doesn't point anywhere it shouldn't
2878 * @global object
2879 * @param $url string relative url within course files directory
2880 * @return mixed boolean false if not OK or cleaned URL as string if OK
2882 function question_url_check($url){
2883 global $CFG;
2884 if ((substr(strtolower($url), 0, strlen($CFG->moddata)) == strtolower($CFG->moddata)) ||
2885 (substr(strtolower($url), 0, 10) == 'backupdata')){
2886 return false;
2887 } else {
2888 return clean_param($url, PARAM_PATH);
2893 * Find all course / site files linked to in a piece of html.
2895 * @global object
2896 * @param string html the html to search
2897 * @param int course search for files for courseid course or set to siteid for
2898 * finding site files.
2899 * @return array files with keys being files.
2901 function question_replace_file_links_in_html($html, $fromcourseid, $tocourseid, $url, $destination, &$changed){
2902 global $CFG;
2903 require_once($CFG->libdir .'/filelib.php');
2904 $tourl = get_file_url("$tocourseid/$destination");
2905 $fromurl = question_file_links_base_url($fromcourseid).preg_quote($url, '!');
2906 $searchfor = array('!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$fromurl.'(")!i',
2907 '!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$fromurl.'(\')!i');
2908 $newhtml = preg_replace($searchfor, '\\1'.$tourl.'\\5', $html);
2909 if ($newhtml != $html){
2910 $changed = true;
2912 return $newhtml;
2916 * @global object
2918 function get_filesdir_from_context($context){
2919 global $DB;
2921 switch ($context->contextlevel){
2922 case CONTEXT_COURSE :
2923 $courseid = $context->instanceid;
2924 break;
2925 case CONTEXT_MODULE :
2926 $courseid = $DB->get_field('course_modules', 'course', array('id'=>$context->instanceid));
2927 break;
2928 case CONTEXT_COURSECAT :
2929 case CONTEXT_SYSTEM :
2930 $courseid = SITEID;
2931 break;
2932 default :
2933 print_error('invalidcontext');
2935 return $courseid;
2938 * Get the real state - the correct question id and answer - for a random
2939 * question.
2940 * @param object $state with property answer.
2941 * @return mixed return integer real question id or false if there was an
2942 * error..
2944 function question_get_real_state($state){
2945 global $OUTPUT;
2946 $realstate = clone($state);
2947 $matches = array();
2948 if (!preg_match('|^random([0-9]+)-(.*)|', $state->answer, $matches)){
2949 echo $OUTPUT->notification(get_string('errorrandom', 'quiz_statistics'));
2950 return false;
2951 } else {
2952 $realstate->question = $matches[1];
2953 $realstate->answer = $matches[2];
2954 return $realstate;
2959 * Update the flagged state of a particular question session.
2961 * @global object
2962 * @param integer $sessionid question_session id.
2963 * @param boolean $newstate the new state for the flag.
2964 * @return boolean success or failure.
2966 function question_update_flag($sessionid, $newstate) {
2967 global $DB;
2968 return $DB->set_field('question_sessions', 'flagged', $newstate, array('id' => $sessionid));
2972 * Update the flagged state of all the questions in an attempt, where a new .
2974 * @global object
2975 * @param integer $sessionid question_session id.
2976 * @param boolean $newstate the new state for the flag.
2977 * @return boolean success or failure.
2979 function question_save_flags($formdata, $attemptid, $questionids) {
2980 global $DB;
2981 $donequestionids = array();
2982 foreach ($formdata as $postvariable => $value) {
2983 list($qid, $key) = question_id_and_key_from_post_name($postvariable);
2984 if ($qid !== false && in_array($qid, $questionids)) {
2985 if ($key == '_flagged') {
2986 $DB->set_field('question_sessions', 'flagged', !empty($value),
2987 array('attemptid' => $attemptid, 'questionid' => $qid));
2988 $donequestionids[$qid] = 1;
2992 foreach ($questionids as $qid) {
2993 if (!isset($donequestionids[$qid])) {
2994 $DB->set_field('question_sessions', 'flagged', 0,
2995 array('attemptid' => $attemptid, 'questionid' => $qid));
3002 * @global object
3003 * @param integer $attemptid the question_attempt id.
3004 * @param integer $questionid the question id.
3005 * @param integer $sessionid the question_session id.
3006 * @param object $user a user, or null to use $USER.
3007 * @return string that needs to be sent to question/toggleflag.php for it to work.
3009 function question_get_toggleflag_checksum($attemptid, $questionid, $sessionid, $user = null) {
3010 if (is_null($user)) {
3011 global $USER;
3012 $user = $USER;
3014 return md5($attemptid . "_" . $user->secret . "_" . $questionid . "_" . $sessionid);
3018 * Adds question bank setting links to the given navigation node if caps are met.
3020 * @param navigation_node $navigationnode The navigation node to add the question branch to
3021 * @param stdClass $context
3022 * @return navigation_node Returns the question branch that was added
3024 function question_extend_settings_navigation(navigation_node $navigationnode, $context) {
3025 global $PAGE;
3027 if ($context->contextlevel == CONTEXT_COURSE) {
3028 $params = array('courseid'=>$context->instanceid);
3029 } else if ($context->contextlevel == CONTEXT_MODULE) {
3030 $params = array('cmid'=>$context->instanceid);
3031 } else {
3032 return;
3035 $questionnode = $navigationnode->add(get_string('questionbank','question'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_CONTAINER);
3037 $contexts = new question_edit_contexts($context);
3038 if ($contexts->have_one_edit_tab_cap('questions')) {
3039 $questionnode->add(get_string('questions', 'quiz'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_SETTING);
3041 if ($contexts->have_one_edit_tab_cap('categories')) {
3042 $questionnode->add(get_string('categories', 'quiz'), new moodle_url('/question/category.php', $params), navigation_node::TYPE_SETTING);
3044 if ($contexts->have_one_edit_tab_cap('import')) {
3045 $questionnode->add(get_string('import', 'quiz'), new moodle_url('/question/import.php', $params), navigation_node::TYPE_SETTING);
3047 if ($contexts->have_one_edit_tab_cap('export')) {
3048 $questionnode->add(get_string('export', 'quiz'), new moodle_url('/question/export.php', $params), navigation_node::TYPE_SETTING);
3051 return $questionnode;
3054 class question_edit_contexts {
3056 public static $CAPS = array(
3057 'editq' => array('moodle/question:add',
3058 'moodle/question:editmine',
3059 'moodle/question:editall',
3060 'moodle/question:viewmine',
3061 'moodle/question:viewall',
3062 'moodle/question:usemine',
3063 'moodle/question:useall',
3064 'moodle/question:movemine',
3065 'moodle/question:moveall'),
3066 'questions'=>array('moodle/question:add',
3067 'moodle/question:editmine',
3068 'moodle/question:editall',
3069 'moodle/question:viewmine',
3070 'moodle/question:viewall',
3071 'moodle/question:movemine',
3072 'moodle/question:moveall'),
3073 'categories'=>array('moodle/question:managecategory'),
3074 'import'=>array('moodle/question:add'),
3075 'export'=>array('moodle/question:viewall', 'moodle/question:viewmine'));
3077 protected $allcontexts;
3080 * @param current context
3082 public function question_edit_contexts($thiscontext){
3083 $pcontextids = get_parent_contexts($thiscontext);
3084 $contexts = array($thiscontext);
3085 foreach ($pcontextids as $pcontextid){
3086 $contexts[] = get_context_instance_by_id($pcontextid);
3088 $this->allcontexts = $contexts;
3091 * @return array all parent contexts
3093 public function all(){
3094 return $this->allcontexts;
3097 * @return object lowest context which must be either the module or course context
3099 public function lowest(){
3100 return $this->allcontexts[0];
3103 * @param string $cap capability
3104 * @return array parent contexts having capability, zero based index
3106 public function having_cap($cap){
3107 $contextswithcap = array();
3108 foreach ($this->allcontexts as $context){
3109 if (has_capability($cap, $context)){
3110 $contextswithcap[] = $context;
3113 return $contextswithcap;
3116 * @param array $caps capabilities
3117 * @return array parent contexts having at least one of $caps, zero based index
3119 public function having_one_cap($caps){
3120 $contextswithacap = array();
3121 foreach ($this->allcontexts as $context){
3122 foreach ($caps as $cap){
3123 if (has_capability($cap, $context)){
3124 $contextswithacap[] = $context;
3125 break; //done with caps loop
3129 return $contextswithacap;
3132 * @param string $tabname edit tab name
3133 * @return array parent contexts having at least one of $caps, zero based index
3135 public function having_one_edit_tab_cap($tabname){
3136 return $this->having_one_cap(self::$CAPS[$tabname]);
3139 * Has at least one parent context got the cap $cap?
3141 * @param string $cap capability
3142 * @return boolean
3144 public function have_cap($cap){
3145 return (count($this->having_cap($cap)));
3149 * Has at least one parent context got one of the caps $caps?
3151 * @param array $caps capability
3152 * @return boolean
3154 public function have_one_cap($caps){
3155 foreach ($caps as $cap) {
3156 if ($this->have_cap($cap)) {
3157 return true;
3160 return false;
3163 * Has at least one parent context got one of the caps for actions on $tabname
3165 * @param string $tabname edit tab name
3166 * @return boolean
3168 public function have_one_edit_tab_cap($tabname){
3169 return $this->have_one_cap(self::$CAPS[$tabname]);
3172 * Throw error if at least one parent context hasn't got the cap $cap
3174 * @param string $cap capability
3176 public function require_cap($cap){
3177 if (!$this->have_cap($cap)){
3178 print_error('nopermissions', '', '', $cap);
3182 * Throw error if at least one parent context hasn't got one of the caps $caps
3184 * @param array $cap capabilities
3186 public function require_one_cap($caps) {
3187 if (!$this->have_one_cap($caps)) {
3188 $capsstring = join($caps, ', ');
3189 print_error('nopermissions', '', '', $capsstring);
3194 * Throw error if at least one parent context hasn't got one of the caps $caps
3196 * @param string $tabname edit tab name
3198 public function require_one_edit_tab_cap($tabname){
3199 if (!$this->have_one_edit_tab_cap($tabname)) {
3200 print_error('nopermissions', '', '', 'access question edit tab '.$tabname);
3206 * Rewrite question url, file_rewrite_pluginfile_urls always build url by
3207 * $file/$contextid/$component/$filearea/$itemid/$pathname_in_text, so we cannot add
3208 * extra questionid and attempted in url by it, so we create quiz_rewrite_question_urls
3209 * to build url here
3211 * @param string $text text being processed
3212 * @param string $file the php script used to serve files
3213 * @param int $contextid
3214 * @param string $component component
3215 * @param string $filearea filearea
3216 * @param array $ids other IDs will be used to check file permission
3217 * @param int $itemid
3218 * @param array $options
3219 * @return string
3221 function quiz_rewrite_question_urls($text, $file, $contextid, $component, $filearea, array $ids, $itemid, array $options=null) {
3222 global $CFG;
3224 $options = (array)$options;
3225 if (!isset($options['forcehttps'])) {
3226 $options['forcehttps'] = false;
3229 if (!$CFG->slasharguments) {
3230 $file = $file . '?file=';
3233 $baseurl = "$CFG->wwwroot/$file/$contextid/$component/$filearea/";
3235 if (!empty($ids)) {
3236 $baseurl .= (implode('/', $ids) . '/');
3239 if ($itemid !== null) {
3240 $baseurl .= "$itemid/";
3243 if ($options['forcehttps']) {
3244 $baseurl = str_replace('http://', 'https://', $baseurl);
3247 return str_replace('@@PLUGINFILE@@/', $baseurl, $text);
3251 * Called by pluginfile.php to serve files related to the 'question' core
3252 * component and for files belonging to qtypes.
3254 * For files that relate to questions in a question_attempt, then we delegate to
3255 * a function in the component that owns the attempt (for example in the quiz,
3256 * or in core question preview) to get necessary inforation.
3258 * (Note that, at the moment, all question file areas relate to questions in
3259 * attempts, so the If at the start of the last paragraph is always true.)
3261 * Does not return, either calls send_file_not_found(); or serves the file.
3263 * @param object $course course settings object
3264 * @param object $context context object
3265 * @param string $component the name of the component we are serving files for.
3266 * @param string $filearea the name of the file area.
3267 * @param array $args the remaining bits of the file path.
3268 * @param bool $forcedownload whether the user must be forced to download the file.
3270 function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload) {
3271 global $DB, $CFG;
3273 $attemptid = (int)array_shift($args);
3274 $questionid = (int)array_shift($args);
3276 require_login($course, false);
3278 if ($attemptid === 0) {
3279 // preview
3280 require_once($CFG->dirroot . '/question/previewlib.php');
3281 return question_preview_question_pluginfile($course, $context,
3282 $component, $filearea, $attemptid, $questionid, $args, $forcedownload);
3284 } else {
3285 $module = $DB->get_field('question_attempts', 'modulename',
3286 array('id' => $attemptid));
3288 $dir = get_component_directory($module);
3289 if (!file_exists("$dir/lib.php")) {
3290 send_file_not_found();
3292 include_once("$dir/lib.php");
3294 $filefunction = $module . '_question_pluginfile';
3295 if (!function_exists($filefunction)) {
3296 send_file_not_found();
3299 $filefunction($course, $context, $component, $filearea, $attemptid, $questionid,
3300 $args, $forcedownload);
3302 send_file_not_found();
3307 * Final test for whether a studnet should be allowed to see a particular file.
3308 * This delegates the decision to the question type plugin.
3310 * @param object $question The question to be rendered.
3311 * @param object $state The state to render the question in.
3312 * @param object $options An object specifying the rendering options.
3313 * @param string $component the name of the component we are serving files for.
3314 * @param string $filearea the name of the file area.
3315 * @param array $args the remaining bits of the file path.
3316 * @param bool $forcedownload whether the user must be forced to download the file.
3318 function question_check_file_access($question, $state, $options, $contextid, $component,
3319 $filearea, $args, $forcedownload) {
3320 global $QTYPES;
3321 return $QTYPES[$question->qtype]->check_file_access($question, $state, $options, $contextid, $component,
3322 $filearea, $args, $forcedownload);