Fixes for Bug MDL-8617 "Implement groupings & course modules..."
[moodle-pu.git] / lib / questionlib.php
blob2713996c7f89d517421a59cf0ae67c4d544ee69a
1 <?php // $Id$
2 /**
3 * Code for handling and processing questions
5 * This is code that is module independent, i.e., can be used by any module that
6 * uses questions, like quiz, lesson, ..
7 * This script also loads the questiontype classes
8 * Code for handling the editing of questions is in {@link question/editlib.php}
10 * TODO: separate those functions which form part of the API
11 * from the helper functions.
13 <<<<<<< questionlib.php
14 * @version $Id$
15 =======
16 * @version $Id$
17 >>>>>>> 1.72.2.6
18 * @author Martin Dougiamas and many others. This has recently been completely
19 * rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of
20 * the Serving Mathematics project
21 * {@link http://maths.york.ac.uk/serving_maths}
22 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
23 * @package question
26 /// CONSTANTS ///////////////////////////////////
28 /**#@+
29 * The different types of events that can create question states
31 define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle
32 define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used)
33 define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated
34 define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle.
35 define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously
36 define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded.
37 define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle.
38 define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked
39 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.
40 define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher
41 /**#@-*/
43 /**#@+
44 * The core question types.
46 define("SHORTANSWER", "shortanswer");
47 define("TRUEFALSE", "truefalse");
48 define("MULTICHOICE", "multichoice");
49 define("RANDOM", "random");
50 define("MATCH", "match");
51 define("RANDOMSAMATCH", "randomsamatch");
52 define("DESCRIPTION", "description");
53 define("NUMERICAL", "numerical");
54 define("MULTIANSWER", "multianswer");
55 define("CALCULATED", "calculated");
56 define("RQP", "rqp");
57 define("ESSAY", "essay");
58 /**#@-*/
60 /**
61 * Constant determines the number of answer boxes supplied in the editing
62 * form for multiple choice and similar question types.
64 define("QUESTION_NUMANS", "10");
66 /**
67 * Constant determines the number of answer boxes supplied in the editing
68 * form for multiple choice and similar question types to start with, with
69 * the option of adding QUESTION_NUMANS_ADD more answers.
71 define("QUESTION_NUMANS_START", 3);
73 /**
74 * Constant determines the number of answer boxes to add in the editing
75 * form for multiple choice and similar question types when the user presses
76 * 'add form fields button'.
78 define("QUESTION_NUMANS_ADD", 3);
80 /**
81 * The options used when popping up a question preview window in Javascript.
83 define('QUESTION_PREVIEW_POPUP_OPTIONS', "'scrollbars=yes,resizable=yes,width=700,height=540'");
85 /**#@+
86 * Option flags for ->optionflags
87 * The options are read out via bitwise operation using these constants
89 /**
90 * Whether the questions is to be run in adaptive mode. If this is not set then
91 * a question closes immediately after the first submission of responses. This
92 * is how question is Moodle always worked before version 1.5
94 define('QUESTION_ADAPTIVE', 1);
96 /**#@-*/
98 /// QTYPES INITIATION //////////////////
99 // These variables get initialised via calls to question_register_questiontype
100 // as the question type classes are included.
101 global $QTYPES, $QTYPE_MENU, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
103 * Array holding question type objects
105 $QTYPES = array();
107 * Array of question types names translated to the user's language
109 * The $QTYPE_MENU array holds the names of all the question types that the user should
110 * be able to create directly. Some internal question types like random questions are excluded.
111 * The complete list of question types can be found in {@link $QTYPES}.
113 $QTYPE_MENU = array();
115 * String in the format "'type1','type2'" that can be used in SQL clauses like
116 * "WHERE q.type IN ($QTYPE_MANUAL)".
118 $QTYPE_MANUAL = '';
120 * String in the format "'type1','type2'" that can be used in SQL clauses like
121 * "WHERE q.type NOT IN ($QTYPE_EXCLUDE_FROM_RANDOM)".
123 $QTYPE_EXCLUDE_FROM_RANDOM = '';
126 * Add a new question type to the various global arrays above.
128 * @param object $qtype An instance of the new question type class.
130 function question_register_questiontype($qtype) {
131 global $QTYPES, $QTYPE_MENU, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
133 $name = $qtype->name();
134 $QTYPES[$name] = $qtype;
135 $menuname = $qtype->menu_name();
136 if ($menuname) {
137 $QTYPE_MENU[$name] = $menuname;
139 if ($qtype->is_manual_graded()) {
140 if ($QTYPE_MANUAL) {
141 $QTYPE_MANUAL .= ',';
143 $QTYPE_MANUAL .= "'$name'";
145 if (!$qtype->is_usable_by_random()) {
146 if ($QTYPE_EXCLUDE_FROM_RANDOM) {
147 $QTYPE_EXCLUDE_FROM_RANDOM .= ',';
149 $QTYPE_EXCLUDE_FROM_RANDOM .= "'$name'";
153 require_once("$CFG->dirroot/question/type/questiontype.php");
155 // Load the questiontype.php file for each question type
156 // These files in turn call question_register_questiontype()
157 // with a new instance of each qtype class.
158 $qtypenames= get_list_of_plugins('question/type');
159 foreach($qtypenames as $qtypename) {
160 // Instanciates all plug-in question types
161 $qtypefilepath= "$CFG->dirroot/question/type/$qtypename/questiontype.php";
163 // echo "Loading $qtypename<br/>"; // Uncomment for debugging
164 if (is_readable($qtypefilepath)) {
165 require_once($qtypefilepath);
169 /// OTHER CLASSES /////////////////////////////////////////////////////////
172 * This holds the options that are set by the course module
174 class cmoptions {
176 * Whether a new attempt should be based on the previous one. If true
177 * then a new attempt will start in a state where all responses are set
178 * to the last responses from the previous attempt.
180 var $attemptonlast = false;
183 * Various option flags. The flags are accessed via bitwise operations
184 * using the constants defined in the CONSTANTS section above.
186 var $optionflags = QUESTION_ADAPTIVE;
189 * Determines whether in the calculation of the score for a question
190 * penalties for earlier wrong responses within the same attempt will
191 * be subtracted.
193 var $penaltyscheme = true;
196 * The maximum time the user is allowed to answer the questions withing
197 * an attempt. This is measured in minutes so needs to be multiplied by
198 * 60 before compared to timestamps. If set to 0 no timelimit will be applied
200 var $timelimit = 0;
203 * Timestamp for the closing time. Responses submitted after this time will
204 * be saved but no credit will be given for them.
206 var $timeclose = 9999999999;
209 * The id of the course from withing which the question is currently being used
211 var $course = SITEID;
214 * Whether the answers in a multiple choice question should be randomly
215 * shuffled when a new attempt is started.
217 var $shuffleanswers = true;
220 * The number of decimals to be shown when scores are printed
222 var $decimalpoints = 2;
226 /// FUNCTIONS //////////////////////////////////////////////////////
229 * Returns an array of names of activity modules that use this question
231 * @param object $questionid
232 * @return array of strings
234 function question_list_instances($questionid) {
235 $instances = array();
236 $modules = get_records('modules');
237 foreach ($modules as $module) {
238 $fn = $module->name.'_question_list_instances';
239 if (function_exists($fn)) {
240 $instances = $instances + $fn($questionid);
243 return $instances;
248 * Returns list of 'allowed' grades for grade selection
249 * formatted suitably for dropdown box function
250 * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
252 function get_grade_options() {
253 // define basic array of grades
254 $grades = array(
256 0.9,
257 0.8,
258 0.75,
259 0.70,
260 0.66666,
261 0.60,
262 0.50,
263 0.40,
264 0.33333,
265 0.30,
266 0.25,
267 0.20,
268 0.16666,
269 0.142857,
270 0.125,
271 0.11111,
272 0.10,
273 0.05,
276 // iterate through grades generating full range of options
277 $gradeoptionsfull = array();
278 $gradeoptions = array();
279 foreach ($grades as $grade) {
280 $percentage = 100 * $grade;
281 $neggrade = -$grade;
282 $gradeoptions["$grade"] = "$percentage %";
283 $gradeoptionsfull["$grade"] = "$percentage %";
284 $gradeoptionsfull["$neggrade"] = -$percentage." %";
286 $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
288 // sort lists
289 arsort($gradeoptions, SORT_NUMERIC);
290 arsort($gradeoptionsfull, SORT_NUMERIC);
292 // construct return object
293 $grades = new stdClass;
294 $grades->gradeoptions = $gradeoptions;
295 $grades->gradeoptionsfull = $gradeoptionsfull;
297 return $grades;
301 * match grade options
302 * if no match return error or match nearest
303 * @param array $gradeoptionsfull list of valid options
304 * @param int $grade grade to be tested
305 * @param string $matchgrades 'error' or 'nearest'
306 * @return mixed either 'fixed' value or false if erro
308 function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
309 // if we just need an error...
310 if ($matchgrades=='error') {
311 foreach($gradeoptionsfull as $value => $option) {
312 // slightly fuzzy test, never check floats for equality :-)
313 if (abs($grade-$value)<0.00001) {
314 return $grade;
317 // didn't find a match so that's an error
318 return false;
320 // work out nearest value
321 else if ($matchgrades=='nearest') {
322 $hownear = array();
323 foreach($gradeoptionsfull as $value => $option) {
324 if ($grade==$value) {
325 return $grade;
327 $hownear[ $value ] = abs( $grade - $value );
329 // reverse sort list of deltas and grab the last (smallest)
330 asort( $hownear, SORT_NUMERIC );
331 reset( $hownear );
332 return key( $hownear );
334 else {
335 return false;
340 * Tests whether a category is in use by any activity module
342 * @return boolean
343 * @param integer $categoryid
344 * @param boolean $recursive Whether to examine category children recursively
346 function question_category_isused($categoryid, $recursive = false) {
348 //Look at each question in the category
349 if ($questions = get_records('question', 'category', $categoryid)) {
350 foreach ($questions as $question) {
351 if (count(question_list_instances($question->id))) {
352 return true;
357 //Look under child categories recursively
358 if ($recursive) {
359 if ($children = get_records('question_categories', 'parent', $categoryid)) {
360 foreach ($children as $child) {
361 if (question_category_isused($child->id, $recursive)) {
362 return true;
368 return false;
372 * Deletes all data associated to an attempt from the database
374 * @param integer $attemptid The id of the attempt being deleted
376 function delete_attempt($attemptid) {
377 global $QTYPES;
379 $states = get_records('question_states', 'attempt', $attemptid);
380 $stateslist = implode(',', array_keys($states));
382 // delete questiontype-specific data
383 foreach ($QTYPES as $qtype) {
384 $qtype->delete_states($stateslist);
387 // delete entries from all other question tables
388 // It is important that this is done only after calling the questiontype functions
389 delete_records("question_states", "attempt", $attemptid);
390 delete_records("question_sessions", "attemptid", $attemptid);
391 delete_records("question_attempts", "id", $attemptid);
393 return;
397 * Deletes question and all associated data from the database
399 * It will not delete a question if it is used by an activity module
400 * @param object $question The question being deleted
402 function delete_question($questionid) {
403 global $QTYPES;
405 // Do not delete a question if it is used by an activity module
406 if (count(question_list_instances($questionid))) {
407 return;
410 // delete questiontype-specific data
411 if ($question = get_record('question', 'id', $questionid)) {
412 if (isset($QTYPES[$question->qtype])) {
413 $QTYPES[$question->qtype]->delete_question($questionid);
415 } else {
416 echo "Question with id $questionid does not exist.<br />";
419 if ($states = get_records('question_states', 'question', $questionid)) {
420 $stateslist = implode(',', array_keys($states));
422 // delete questiontype-specific data
423 foreach ($QTYPES as $qtype) {
424 $qtype->delete_states($stateslist);
428 // delete entries from all other question tables
429 // It is important that this is done only after calling the questiontype functions
430 delete_records("question_answers", "question", $questionid);
431 delete_records("question_states", "question", $questionid);
432 delete_records("question_sessions", "questionid", $questionid);
434 // Now recursively delete all child questions
435 if ($children = get_records('question', 'parent', $questionid)) {
436 foreach ($children as $child) {
437 if ($child->id != $questionid) {
438 delete_question($child->id);
443 // Finally delete the question record itself
444 delete_records('question', 'id', $questionid);
446 return;
450 * All non-used question categories and their questions are deleted and
451 * categories still used by other courses are moved to the site course.
453 * @param object $course an object representing the course
454 * @param boolean $feedback to specify if the process must output a summary of its work
455 * @return boolean
457 function question_delete_course($course, $feedback=true) {
459 global $CFG, $QTYPES;
461 //To detect if we have created the "container category"
462 $concatid = 0;
464 //The "container" category we'll create if we need if
465 $contcat = new object;
467 //To temporary store changes performed with parents
468 $parentchanged = array();
470 //To store feedback to be showed at the end of the process
471 $feedbackdata = array();
473 //Cache some strings
474 $strcatcontainer=get_string('containercategorycreated', 'quiz');
475 $strcatmoved = get_string('usedcategorymoved', 'quiz');
476 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
478 if ($categories = get_records('question_categories', 'course', $course->id, 'parent', 'id, parent, name, course')) {
480 //Sort categories following their tree (parent-child) relationships
481 $categories = sort_categories_by_tree($categories);
483 foreach ($categories as $cat) {
485 //Get the full record
486 $category = get_record('question_categories', 'id', $cat->id);
488 //Check if the category is being used anywhere
489 if(question_category_isused($category->id, true)) {
490 //It's being used. Cannot delete it, so:
491 //Create a container category in SITEID course if it doesn't exist
492 if (!$concatid) {
493 $concat = new stdClass;
494 $concat->course = SITEID;
495 if (!isset($course->shortname)) {
496 $course->shortname = 'id=' . $course->id;
498 $concat->name = get_string('savedfromdeletedcourse', 'quiz', $course->shortname);
499 $concat->info = $concat->name;
500 $concat->publish = 1;
501 $concat->stamp = make_unique_id_code();
502 $concatid = insert_record('question_categories', $concat);
504 //Fill feedback
505 $feedbackdata[] = array($concat->name, $strcatcontainer);
507 //Move the category to the container category in SITEID course
508 $category->course = SITEID;
509 //Assign to container if the category hasn't parent or if the parent is wrong (not belongs to the course)
510 if (!$category->parent || !isset($categories[$category->parent])) {
511 $category->parent = $concatid;
513 //If it's being used, its publish field should be 1
514 $category->publish = 1;
515 //Let's update it
516 update_record('question_categories', $category);
518 //Save this parent change for future use
519 $parentchanged[$category->id] = $category->parent;
521 //Fill feedback
522 $feedbackdata[] = array($category->name, $strcatmoved);
524 } else {
525 //Category isn't being used so:
526 //Delete it completely (questions and category itself)
527 //deleting questions
528 if ($questions = get_records("question", "category", $category->id)) {
529 foreach ($questions as $question) {
530 delete_question($question->id);
532 delete_records("question", "category", $category->id);
534 //delete the category
535 delete_records('question_categories', 'id', $category->id);
537 //Save this parent change for future use
538 if (!empty($category->parent)) {
539 $parentchanged[$category->id] = $category->parent;
540 } else {
541 $parentchanged[$category->id] = $concatid;
544 //Update all its child categories to re-parent them to grandparent.
545 set_field ('question_categories', 'parent', $parentchanged[$category->id], 'parent', $category->id);
547 //Fill feedback
548 $feedbackdata[] = array($category->name, $strcatdeleted);
551 //Inform about changes performed if feedback is enabled
552 if ($feedback) {
553 $table = new stdClass;
554 $table->head = array(get_string('category','quiz'), get_string('action'));
555 $table->data = $feedbackdata;
556 print_table($table);
559 return true;
563 * Private function to factor common code out of get_question_options().
565 * @param object $question the question to tidy.
566 * @return boolean true if successful, else false.
568 function _tidy_question(&$question) {
569 global $QTYPES;
570 if (!array_key_exists($question->qtype, $QTYPES)) {
571 $question->qtype = 'missingtype';
572 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
574 $question->name_prefix = question_make_name_prefix($question->id);
575 return $QTYPES[$question->qtype]->get_question_options($question);
579 * Updates the question objects with question type specific
580 * information by calling {@link get_question_options()}
582 * Can be called either with an array of question objects or with a single
583 * question object.
585 * @param mixed $questions Either an array of question objects to be updated
586 * or just a single question object
587 * @return bool Indicates success or failure.
589 function get_question_options(&$questions) {
590 if (is_array($questions)) { // deal with an array of questions
591 foreach ($questions as $i => $notused) {
592 if (!_tidy_question($questions[$i])) {
593 return false;
596 return true;
597 } else { // deal with single question
598 return _tidy_question($questions);
603 * Loads the most recent state of each question session from the database
604 * or create new one.
606 * For each question the most recent session state for the current attempt
607 * is loaded from the question_states table and the question type specific data and
608 * responses are added by calling {@link restore_question_state()} which in turn
609 * calls {@link restore_session_and_responses()} for each question.
610 * If no states exist for the question instance an empty state object is
611 * created representing the start of a session and empty question
612 * type specific information and responses are created by calling
613 * {@link create_session_and_responses()}.
615 * @return array An array of state objects representing the most recent
616 * states of the question sessions.
617 * @param array $questions The questions for which sessions are to be restored or
618 * created.
619 * @param object $cmoptions
620 * @param object $attempt The attempt for which the question sessions are
621 * to be restored or created.
623 function get_question_states(&$questions, $cmoptions, $attempt) {
624 global $CFG, $QTYPES;
626 // get the question ids
627 $ids = array_keys($questions);
628 $questionlist = implode(',', $ids);
630 // The question field must be listed first so that it is used as the
631 // array index in the array returned by get_records_sql
632 $statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment';
633 // Load the newest states for the questions
634 $sql = "SELECT $statefields".
635 " FROM {$CFG->prefix}question_states s,".
636 " {$CFG->prefix}question_sessions n".
637 " WHERE s.id = n.newest".
638 " AND n.attemptid = '$attempt->uniqueid'".
639 " AND n.questionid IN ($questionlist)";
640 $states = get_records_sql($sql);
642 // Load the newest graded states for the questions
643 $sql = "SELECT $statefields".
644 " FROM {$CFG->prefix}question_states s,".
645 " {$CFG->prefix}question_sessions n".
646 " WHERE s.id = n.newgraded".
647 " AND n.attemptid = '$attempt->uniqueid'".
648 " AND n.questionid IN ($questionlist)";
649 $gradedstates = get_records_sql($sql);
651 // loop through all questions and set the last_graded states
652 foreach ($ids as $i) {
653 if (isset($states[$i])) {
654 restore_question_state($questions[$i], $states[$i]);
655 if (isset($gradedstates[$i])) {
656 restore_question_state($questions[$i], $gradedstates[$i]);
657 $states[$i]->last_graded = $gradedstates[$i];
658 } else {
659 $states[$i]->last_graded = clone($states[$i]);
661 } else {
662 // create a new empty state
663 $states[$i] = new object;
664 $states[$i]->attempt = $attempt->uniqueid;
665 $states[$i]->question = (int) $i;
666 $states[$i]->seq_number = 0;
667 $states[$i]->timestamp = $attempt->timestart;
668 $states[$i]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
669 $states[$i]->grade = 0;
670 $states[$i]->raw_grade = 0;
671 $states[$i]->penalty = 0;
672 $states[$i]->sumpenalty = 0;
673 $states[$i]->manualcomment = '';
674 $states[$i]->responses = array('' => '');
675 // Prevent further changes to the session from incrementing the
676 // sequence number
677 $states[$i]->changed = true;
679 // Create the empty question type specific information
680 if (!$QTYPES[$questions[$i]->qtype]->create_session_and_responses(
681 $questions[$i], $states[$i], $cmoptions, $attempt)) {
682 return false;
684 $states[$i]->last_graded = clone($states[$i]);
687 return $states;
692 * Creates the run-time fields for the states
694 * Extends the state objects for a question by calling
695 * {@link restore_session_and_responses()}
696 * @param object $question The question for which the state is needed
697 * @param object $state The state as loaded from the database
698 * @return boolean Represents success or failure
700 function restore_question_state(&$question, &$state) {
701 global $QTYPES;
703 // initialise response to the value in the answer field
704 $state->responses = array('' => addslashes($state->answer));
705 unset($state->answer);
706 $state->manualcomment = isset($state->manualcomment) ? addslashes($state->manualcomment) : '';
708 // Set the changed field to false; any code which changes the
709 // question session must set this to true and must increment
710 // ->seq_number. The save_question_session
711 // function will save the new state object to the database if the field is
712 // set to true.
713 $state->changed = false;
715 // Load the question type specific data
716 return $QTYPES[$question->qtype]
717 ->restore_session_and_responses($question, $state);
722 * Saves the current state of the question session to the database
724 * The state object representing the current state of the session for the
725 * question is saved to the question_states table with ->responses[''] saved
726 * to the answer field of the database table. The information in the
727 * question_sessions table is updated.
728 * The question type specific data is then saved.
729 * @return mixed The id of the saved or updated state or false
730 * @param object $question The question for which session is to be saved.
731 * @param object $state The state information to be saved. In particular the
732 * most recent responses are in ->responses. The object
733 * is updated to hold the new ->id.
735 function save_question_session(&$question, &$state) {
736 global $QTYPES;
737 // Check if the state has changed
738 if (!$state->changed && isset($state->id)) {
739 return $state->id;
741 // Set the legacy answer field
742 $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
744 // Save the state
745 if (!empty($state->update)) { // this forces the old state record to be overwritten
746 update_record('question_states', $state);
747 } else {
748 if (!$state->id = insert_record('question_states', $state)) {
749 unset($state->id);
750 unset($state->answer);
751 return false;
755 // create or update the session
756 if (!$session = get_record('question_sessions', 'attemptid',
757 $state->attempt, 'questionid', $question->id)) {
758 $session->attemptid = $state->attempt;
759 $session->questionid = $question->id;
760 $session->newest = $state->id;
761 // The following may seem weird, but the newgraded field needs to be set
762 // already even if there is no graded state yet.
763 $session->newgraded = $state->id;
764 $session->sumpenalty = $state->sumpenalty;
765 $session->manualcomment = $state->manualcomment;
766 if (!insert_record('question_sessions', $session)) {
767 error('Could not insert entry in question_sessions');
769 } else {
770 $session->newest = $state->id;
771 if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
772 // this state is graded or newly opened, so it goes into the lastgraded field as well
773 $session->newgraded = $state->id;
774 $session->sumpenalty = $state->sumpenalty;
775 $session->manualcomment = $state->manualcomment;
776 } else {
777 $session->manualcomment = addslashes($session->manualcomment);
779 update_record('question_sessions', $session);
782 unset($state->answer);
784 // Save the question type specific state information and responses
785 if (!$QTYPES[$question->qtype]->save_session_and_responses(
786 $question, $state)) {
787 return false;
789 // Reset the changed flag
790 $state->changed = false;
791 return $state->id;
795 * Determines whether a state has been graded by looking at the event field
797 * @return boolean true if the state has been graded
798 * @param object $state
800 function question_state_is_graded($state) {
801 return ($state->event == QUESTION_EVENTGRADE
802 or $state->event == QUESTION_EVENTCLOSEANDGRADE
803 or $state->event == QUESTION_EVENTMANUALGRADE);
807 * Determines whether a state has been closed by looking at the event field
809 * @return boolean true if the state has been closed
810 * @param object $state
812 function question_state_is_closed($state) {
813 return ($state->event == QUESTION_EVENTCLOSE
814 or $state->event == QUESTION_EVENTCLOSEANDGRADE
815 or $state->event == QUESTION_EVENTMANUALGRADE);
820 * Extracts responses from submitted form
822 * This can extract the responses given to one or several questions present on a page
823 * It returns an array with one entry for each question, indexed by question id
824 * Each entry is an object with the properties
825 * ->event The event that has triggered the submission. This is determined by which button
826 * the user has pressed.
827 * ->responses An array holding the responses to an individual question, indexed by the
828 * name of the corresponding form element.
829 * ->timestamp A unix timestamp
830 * @return array array of action objects, indexed by question ids.
831 * @param array $questions an array containing at least all questions that are used on the form
832 * @param array $formdata the data submitted by the form on the question page
833 * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted
835 function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
837 $time = time();
838 $actions = array();
839 foreach ($formdata as $key => $response) {
840 // Get the question id from the response name
841 if (false !== ($quid = question_get_id_from_name_prefix($key))) {
842 // check if this is a valid id
843 if (!isset($questions[$quid])) {
844 error('Form contained question that is not in questionids');
847 // Remove the name prefix from the name
848 //decrypt trying
849 $key = substr($key, strlen($questions[$quid]->name_prefix));
850 if (false === $key) {
851 $key = '';
853 // Check for question validate and mark buttons & set events
854 if ($key === 'validate') {
855 $actions[$quid]->event = QUESTION_EVENTVALIDATE;
856 } else if ($key === 'submit') {
857 $actions[$quid]->event = QUESTION_EVENTSUBMIT;
858 } else {
859 $actions[$quid]->event = $defaultevent;
862 // Update the state with the new response
863 $actions[$quid]->responses[$key] = $response;
865 // Set the timestamp
866 $actions[$quid]->timestamp = $time;
869 foreach ($actions as $quid => $notused) {
870 ksort($actions[$quid]->responses);
872 return $actions;
877 * Returns the html for question feedback image.
878 * @param float $fraction value representing the correctness of the user's
879 * response to a question.
880 * @param boolean $selected whether or not the answer is the one that the
881 * user picked.
882 * @return string
884 function question_get_feedback_image($fraction, $selected=true) {
886 global $CFG;
888 if ($fraction >= 1.0) {
889 if ($selected) {
890 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_green_big.gif" '.
891 'alt="'.get_string('correct', 'quiz').'" class="icon" />';
892 } else {
893 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_green_small.gif" '.
894 'alt="'.get_string('correct', 'quiz').'" class="icon" />';
896 } else if ($fraction > 0.0 && $fraction < 1.0) {
897 if ($selected) {
898 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_amber_big.gif" '.
899 'alt="'.get_string('partiallycorrect', 'quiz').'" class="icon" />';
900 } else {
901 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_amber_small.gif" '.
902 'alt="'.get_string('partiallycorrect', 'quiz').'" class="icon" />';
904 } else {
905 if ($selected) {
906 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/cross_red_big.gif" '.
907 'alt="'.get_string('incorrect', 'quiz').'" class="icon" />';
908 } else {
909 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/cross_red_small.gif" '.
910 'alt="'.get_string('incorrect', 'quiz').'" class="icon" />';
913 return $feedbackimg;
918 * Returns the class name for question feedback.
919 * @param float $fraction value representing the correctness of the user's
920 * response to a question.
921 * @return string
923 function question_get_feedback_class($fraction) {
925 global $CFG;
927 if ($fraction >= 1.0) {
928 $class = 'correct';
929 } else if ($fraction > 0.0 && $fraction < 1.0) {
930 $class = 'partiallycorrect';
931 } else {
932 $class = 'incorrect';
934 return $class;
939 * For a given question in an attempt we walk the complete history of states
940 * and recalculate the grades as we go along.
942 * This is used when a question is changed and old student
943 * responses need to be marked with the new version of a question.
945 * TODO: Make sure this is not quiz-specific
947 * @return boolean Indicates whether the grade has changed
948 * @param object $question A question object
949 * @param object $attempt The attempt, in which the question needs to be regraded.
950 * @param object $cmoptions
951 * @param boolean $verbose Optional. Whether to print progress information or not.
953 function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false) {
955 // load all states for this question in this attempt, ordered in sequence
956 if ($states = get_records_select('question_states',
957 "attempt = '{$attempt->uniqueid}' AND question = '{$question->id}'",
958 'seq_number ASC')) {
959 $states = array_values($states);
961 // Subtract the grade for the latest state from $attempt->sumgrades to get the
962 // sumgrades for the attempt without this question.
963 $attempt->sumgrades -= $states[count($states)-1]->grade;
965 // Initialise the replaystate
966 $state = clone($states[0]);
967 $state->manualcomment = get_field('question_sessions', 'manualcomment', 'attemptid',
968 $attempt->uniqueid, 'questionid', $question->id);
969 restore_question_state($question, $state);
970 $state->sumpenalty = 0.0;
971 $replaystate = clone($state);
972 $replaystate->last_graded = $state;
974 $changed = false;
975 for($j = 1; $j < count($states); $j++) {
976 restore_question_state($question, $states[$j]);
977 $action = new stdClass;
978 $action->responses = $states[$j]->responses;
979 $action->timestamp = $states[$j]->timestamp;
981 // Change event to submit so that it will be reprocessed
982 if (QUESTION_EVENTCLOSE == $states[$j]->event
983 or QUESTION_EVENTGRADE == $states[$j]->event
984 or QUESTION_EVENTCLOSEANDGRADE == $states[$j]->event) {
985 $action->event = QUESTION_EVENTSUBMIT;
987 // By default take the event that was saved in the database
988 } else {
989 $action->event = $states[$j]->event;
992 if ($action->event == QUESTION_EVENTMANUALGRADE) {
993 question_process_comment($question, $replaystate, $attempt,
994 $replaystate->manualcomment, $states[$j]->grade);
995 } else {
997 // Reprocess (regrade) responses
998 if (!question_process_responses($question, $replaystate,
999 $action, $cmoptions, $attempt)) {
1000 $verbose && notify("Couldn't regrade state #{$state->id}!");
1004 // We need rounding here because grades in the DB get truncated
1005 // e.g. 0.33333 != 0.3333333, but we want them to be equal here
1006 if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
1007 or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
1008 or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
1009 $changed = true;
1012 $replaystate->id = $states[$j]->id;
1013 $replaystate->changed = true;
1014 $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
1015 save_question_session($question, $replaystate);
1017 if ($changed) {
1018 // TODO, call a method in quiz to do this, where 'quiz' comes from
1019 // the question_attempts table.
1020 update_record('quiz_attempts', $attempt);
1023 return $changed;
1025 return false;
1029 * Processes an array of student responses, grading and saving them as appropriate
1031 * @return boolean Indicates success/failure
1032 * @param object $question Full question object, passed by reference
1033 * @param object $state Full state object, passed by reference
1034 * @param object $action object with the fields ->responses which
1035 * is an array holding the student responses,
1036 * ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
1037 * and ->timestamp which is a timestamp from when the responses
1038 * were submitted by the student.
1039 * @param object $cmoptions
1040 * @param object $attempt The attempt is passed by reference so that
1041 * during grading its ->sumgrades field can be updated
1043 function question_process_responses(&$question, &$state, $action, $cmoptions, &$attempt) {
1044 global $QTYPES;
1046 // if no responses are set initialise to empty response
1047 if (!isset($action->responses)) {
1048 $action->responses = array('' => '');
1051 // make sure these are gone!
1052 unset($action->responses['submit'], $action->responses['validate']);
1054 // Check the question session is still open
1055 if (question_state_is_closed($state)) {
1056 return true;
1059 // If $action->event is not set that implies saving
1060 if (! isset($action->event)) {
1061 $action->event = QUESTION_EVENTSAVE;
1063 // If submitted then compare against last graded
1064 // responses, not last given responses in this case
1065 if (question_isgradingevent($action->event)) {
1066 $state->responses = $state->last_graded->responses;
1069 // Check for unchanged responses (exactly unchanged, not equivalent).
1070 // We also have to catch questions that the student has not yet attempted
1071 $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
1072 if ($state->last_graded->event == QUESTION_EVENTOPEN && question_isgradingevent($action->event)) {
1073 $sameresponses = false;
1076 // If the response has not been changed then we do not have to process it again
1077 // unless the attempt is closing or validation is requested
1078 if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
1079 and QUESTION_EVENTVALIDATE != $action->event) {
1080 return true;
1083 // Roll back grading information to last graded state and set the new
1084 // responses
1085 $newstate = clone($state->last_graded);
1086 $newstate->responses = $action->responses;
1087 $newstate->seq_number = $state->seq_number + 1;
1088 $newstate->changed = true; // will assure that it gets saved to the database
1089 $newstate->last_graded = clone($state->last_graded);
1090 $newstate->timestamp = $action->timestamp;
1091 $state = $newstate;
1093 // Set the event to the action we will perform. The question type specific
1094 // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
1095 // attempt at the question causes the session to close
1096 $state->event = $action->event;
1098 if (!question_isgradingevent($action->event)) {
1099 // Grade the response but don't update the overall grade
1100 $QTYPES[$question->qtype]->grade_responses(
1101 $question, $state, $cmoptions);
1102 // Don't allow the processing to change the event type
1103 $state->event = $action->event;
1105 } else { // grading event
1107 // Unless the attempt is closing, we want to work out if the current responses
1108 // (or equivalent responses) were already given in the last graded attempt.
1109 if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
1110 $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
1111 $state->event = QUESTION_EVENTDUPLICATE;
1114 // If we did not find a duplicate or if the attempt is closing, perform grading
1115 if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
1116 QUESTION_EVENTCLOSE == $action->event) {
1117 // Decrease sumgrades by previous grade and then later add new grade
1118 $attempt->sumgrades -= (float)$state->last_graded->grade;
1120 $QTYPES[$question->qtype]->grade_responses(
1121 $question, $state, $cmoptions);
1122 // Calculate overall grade using correct penalty method
1123 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1125 $attempt->sumgrades += (float)$state->grade;
1128 // If the state was graded we need to update the last_graded field.
1129 if (question_state_is_graded($state)) {
1130 unset($state->last_graded);
1131 $state->last_graded = clone($state);
1132 unset($state->last_graded->changed);
1135 $attempt->timemodified = $action->timestamp;
1137 return true;
1141 * Determine if event requires grading
1143 function question_isgradingevent($event) {
1144 return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
1148 * Applies the penalty from the previous graded responses to the raw grade
1149 * for the current responses
1151 * The grade for the question in the current state is computed by subtracting the
1152 * penalty accumulated over the previous graded responses at the question from the
1153 * raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
1154 * the grade is set to zero. The ->grade field of the state object is modified to
1155 * reflect the new grade but is never allowed to decrease.
1156 * @param object $question The question for which the penalty is to be applied.
1157 * @param object $state The state for which the grade is to be set from the
1158 * raw grade and the cumulative penalty from the last
1159 * graded state. The ->grade field is updated by applying
1160 * the penalty scheme determined in $cmoptions to the ->raw_grade and
1161 * ->last_graded->penalty fields.
1162 * @param object $cmoptions The options set by the course module.
1163 * The ->penaltyscheme field determines whether penalties
1164 * for incorrect earlier responses are subtracted.
1166 function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
1167 // deal with penalty
1168 if ($cmoptions->penaltyscheme) {
1169 $state->grade = $state->raw_grade - $state->sumpenalty;
1170 $state->sumpenalty += (float) $state->penalty;
1171 } else {
1172 $state->grade = $state->raw_grade;
1175 // deal with timelimit
1176 if ($cmoptions->timelimit) {
1177 // We allow for 5% uncertainty in the following test
1178 if (($state->timestamp - $attempt->timestart) > ($cmoptions->timelimit * 63)) {
1179 $state->grade = 0;
1183 // deal with closing time
1184 if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
1185 and !$attempt->preview) { // ignore closing time for previews
1186 $state->grade = 0;
1189 // Ensure that the grade does not go down
1190 $state->grade = max($state->grade, $state->last_graded->grade);
1194 * Print the icon for the question type
1196 * @param object $question The question object for which the icon is required
1197 * @param boolean $editlink If true then the icon is a link to the question
1198 * edit page.
1199 * @param boolean $return If true the functions returns the link as a string
1201 function print_question_icon($question, $editlink=true, $return = false) {
1202 global $QTYPES, $CFG;
1204 $namestr = $QTYPES[$question->qtype]->menu_name();
1205 $html = '<img src="'.$CFG->wwwroot.'/question/type/'.
1206 $question->qtype.'/icon.gif" alt="'.
1207 $namestr.'" title="'.$namestr.'" />';
1209 if ($editlink) {
1210 $html = "<a href=\"$CFG->wwwroot/question/question.php?id=$question->id\" title=\""
1211 .$question->qtype."\">".
1212 $html."</a>\n";
1214 if ($return) {
1215 return $html;
1216 } else {
1217 echo $html;
1222 * Returns a html link to the question image if there is one
1224 * @return string The html image tag or the empy string if there is no image.
1225 * @param object $question The question object
1227 function get_question_image($question, $courseid) {
1229 global $CFG;
1230 $img = '';
1232 if ($question->image) {
1234 if (substr(strtolower($question->image), 0, 7) == 'http://') {
1235 $img .= $question->image;
1237 } else if ($CFG->slasharguments) { // Use this method if possible for better caching
1238 $img .= "$CFG->wwwroot/file.php/$courseid/$question->image";
1240 } else {
1241 $img .= "$CFG->wwwroot/file.php?file=/$courseid/$question->image";
1244 return $img;
1247 function question_print_comment_box($question, $state, $attempt, $url) {
1248 global $CFG;
1250 $prefix = 'response';
1251 $usehtmleditor = can_use_richtext_editor();
1252 $grade = round($state->last_graded->grade, 3);
1253 echo '<form method="post" action="'.$url.'">';
1254 include($CFG->dirroot.'/question/comment.html');
1255 echo '<input type="hidden" name="attempt" value="'.$attempt->uniqueid.'" />';
1256 echo '<input type="hidden" name="question" value="'.$question->id.'" />';
1257 echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
1258 echo '<input type="submit" name="submit" value="'.get_string('save', 'quiz').'" />';
1259 echo '</form>';
1261 if ($usehtmleditor) {
1262 use_html_editor();
1266 function question_process_comment($question, &$state, &$attempt, $comment, $grade) {
1268 // Update the comment and save it in the database
1269 $state->manualcomment = $comment;
1270 if (!set_field('question_sessions', 'manualcomment', $comment, 'attemptid', $attempt->uniqueid, 'questionid', $question->id)) {
1271 error("Cannot save comment");
1274 // Update the attempt if the score has changed.
1275 if (abs($state->last_graded->grade - $grade) > 0.002) {
1276 $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
1277 $attempt->timemodified = time();
1278 if (!update_record('quiz_attempts', $attempt)) {
1279 error('Failed to save the current quiz attempt!');
1283 // Update the state if either the score has changed, or this is the first
1284 // manual grade event.
1285 // We don't need to store the modified state in the database, we just need
1286 // to set the $state->changed flag.
1287 if (abs($state->last_graded->grade - $grade) > 0.002 ||
1288 $state->last_graded->event != QUESTION_EVENTMANUALGRADE) {
1290 // We want to update existing state (rather than creating new one) if it
1291 // was itself created by a manual grading event.
1292 $state->update = ($state->event == QUESTION_EVENTMANUALGRADE) ? 1 : 0;
1294 // Update the other parts of the state object.
1295 $state->raw_grade = $grade;
1296 $state->grade = $grade;
1297 $state->penalty = 0;
1298 $state->timestamp = time();
1299 $state->seq_number++;
1300 $state->event = QUESTION_EVENTMANUALGRADE;
1302 // Update the last graded state (don't simplify!)
1303 unset($state->last_graded);
1304 $state->last_graded = clone($state);
1306 // We need to indicate that the state has changed in order for it to be saved.
1307 $state->changed = 1;
1313 * Construct name prefixes for question form element names
1315 * Construct the name prefix that should be used for example in the
1316 * names of form elements created by questions.
1317 * This is called by {@link get_question_options()}
1318 * to set $question->name_prefix.
1319 * This name prefix includes the question id which can be
1320 * extracted from it with {@link question_get_id_from_name_prefix()}.
1322 * @return string
1323 * @param integer $id The question id
1325 function question_make_name_prefix($id) {
1326 return 'resp' . $id . '_';
1330 * Extract question id from the prefix of form element names
1332 * @return integer The question id
1333 * @param string $name The name that contains a prefix that was
1334 * constructed with {@link question_make_name_prefix()}
1336 function question_get_id_from_name_prefix($name) {
1337 if (!preg_match('/^resp([0-9]+)_/', $name, $matches))
1338 return false;
1339 return (integer) $matches[1];
1343 * Returns the unique id for a new attempt
1345 * Every module can keep their own attempts table with their own sequential ids but
1346 * the question code needs to also have a unique id by which to identify all these
1347 * attempts. Hence a module, when creating a new attempt, calls this function and
1348 * stores the return value in the 'uniqueid' field of its attempts table.
1350 function question_new_attempt_uniqueid($modulename='quiz') {
1351 global $CFG;
1352 $attempt = new stdClass;
1353 $attempt->modulename = $modulename;
1354 if (!$id = insert_record('question_attempts', $attempt)) {
1355 error('Could not create new entry in question_attempts table');
1357 return $id;
1361 * Creates a stamp that uniquely identifies this version of the question
1363 * In future we want this to use a hash of the question data to guarantee that
1364 * identical versions have the same version stamp.
1366 * @param object $question
1367 * @return string A unique version stamp
1369 function question_hash($question) {
1370 return make_unique_id_code();
1374 /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
1377 * Prints a question
1379 * Simply calls the question type specific print_question() method.
1380 * @param object $question The question to be rendered.
1381 * @param object $state The state to render the question in.
1382 * @param integer $number The number for this question.
1383 * @param object $cmoptions The options specified by the course module
1384 * @param object $options An object specifying the rendering options.
1386 function print_question(&$question, &$state, $number, $cmoptions, $options=null) {
1387 global $QTYPES;
1389 $QTYPES[$question->qtype]->print_question($question, $state, $number,
1390 $cmoptions, $options);
1393 * Saves question options
1395 * Simply calls the question type specific save_question_options() method.
1397 function save_question_options($question) {
1398 global $QTYPES;
1400 $QTYPES[$question->qtype]->save_question_options($question);
1404 * Gets all teacher stored answers for a given question
1406 * Simply calls the question type specific get_all_responses() method.
1408 // ULPGC ecastro
1409 function get_question_responses($question, $state) {
1410 global $QTYPES;
1411 $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
1412 return $r;
1417 * Gets the response given by the user in a particular state
1419 * Simply calls the question type specific get_actual_response() method.
1421 // ULPGC ecastro
1422 function get_question_actual_response($question, $state) {
1423 global $QTYPES;
1425 $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
1426 return $r;
1430 * TODO: document this
1432 // ULPGc ecastro
1433 function get_question_fraction_grade($question, $state) {
1434 global $QTYPES;
1436 $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
1437 return $r;
1441 /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
1444 * returns the categories with their names ordered following parent-child relationships
1445 * finally it tries to return pending categories (those being orphaned, whose parent is
1446 * incorrect) to avoid missing any category from original array.
1448 function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
1449 $children = array();
1450 $keys = array_keys($categories);
1452 foreach ($keys as $key) {
1453 if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
1454 $children[$key] = $categories[$key];
1455 $categories[$key]->processed = true;
1456 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1459 //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
1460 if ($level == 1) {
1461 foreach ($keys as $key) {
1462 //If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
1463 if (!isset($categories[$key]->processed) && !record_exists('question_categories', 'course', $categories[$key]->course, 'id', $categories[$key]->parent)) {
1464 $children[$key] = $categories[$key];
1465 $categories[$key]->processed = true;
1466 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1470 return $children;
1474 * Private method, only for the use of add_indented_names().
1476 * Recursively adds an indentedname field to each category, starting with the category
1477 * with id $id, and dealing with that category and all its children, and
1478 * return a new array, with those categories in the right order.
1480 * @param array $categories an array of categories which has had childids
1481 * fields added by flatten_category_tree(). Passed by reference for
1482 * performance only. It is not modfied.
1483 * @param int $id the category to start the indenting process from.
1484 * @param int $depth the indent depth. Used in recursive calls.
1485 * @return array a new array of categories, in the right order for the tree.
1487 function flatten_category_tree(&$categories, $id, $depth = 0) {
1489 // Indent the name of this category.
1490 $newcategories = array();
1491 $newcategories[$id] = $categories[$id];
1492 $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
1494 // Recursively indent the children.
1495 foreach ($categories[$id]->childids as $childid) {
1496 $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1);
1499 // Remove the childids array that were temporarily added.
1500 unset($newcategories[$id]->childids);
1502 return $newcategories;
1506 * Format categories into an indented list reflecting the tree structure.
1508 * @param array $categories An array of category objects, for example from the.
1509 * @return array The formatted list of categories.
1511 function add_indented_names($categories) {
1513 // Add an array to each category to hold the child category ids. This array will be removed
1514 // again by flatten_category_tree(). It should not be used outside these two functions.
1515 foreach (array_keys($categories) as $id) {
1516 $categories[$id]->childids = array();
1519 // Build the tree structure, and record which categories are top-level.
1520 // We have to be careful, because the categories array may include published
1521 // categories from other courses, but not their parents.
1522 $toplevelcategoryids = array();
1523 foreach (array_keys($categories) as $id) {
1524 if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
1525 $categories[$categories[$id]->parent]->childids[] = $id;
1526 } else {
1527 $toplevelcategoryids[] = $id;
1531 // Flatten the tree to and add the indents.
1532 $newcategories = array();
1533 foreach ($toplevelcategoryids as $id) {
1534 $newcategories = $newcategories + flatten_category_tree($categories, $id);
1537 return $newcategories;
1541 * Output a select menu of question categories.
1543 * Categories from this course and (optionally) published categories from other courses
1544 * are included. Optionally, only categories the current user may edit can be included.
1546 * @param integer $courseid the id of the course to get the categories for.
1547 * @param integer $published if true, include publised categories from other courses.
1548 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
1549 * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
1551 function question_category_select_menu($courseid, $published = false, $only_editable = false, $selected = "") {
1552 global $CFG;
1554 // get sql fragment for published
1555 $publishsql="";
1556 if ($published) {
1557 $publishsql = " OR publish = 1";
1560 $categories = get_records_sql("
1561 SELECT cat.*, c.shortname AS coursename
1562 FROM {$CFG->prefix}question_categories cat, {$CFG->prefix}course c
1563 WHERE c.id = cat.course AND (cat.course = $courseid $publishsql)
1564 ORDER BY cat.parent, cat.sortorder, cat.name ASC");
1566 $categories = add_indented_names($categories);
1568 echo "<select name=\"category\">\n";
1569 foreach ($categories as $category) {
1570 $cid = $category->id;
1571 $cname = question_category_coursename($category, $courseid);
1572 $seltxt = "";
1573 if ($cid==$selected) {
1574 $seltxt = "selected=\"selected\"";
1576 if ((!$only_editable) || has_capability('moodle/question:managecategory', get_context_instance(CONTEXT_COURSE, $category->course))) {
1577 echo " <option value=\"$cid\" $seltxt>$cname</option>\n";
1580 echo "</select>\n";
1584 * Output an array of question categories.
1586 * Categories from this course and (optionally) published categories from other courses
1587 * are included. Optionally, only categories the current user may edit can be included.
1589 * @param integer $courseid the id of the course to get the categories for.
1590 * @param integer $published if true, include publised categories from other courses.
1591 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
1592 * @return array The list of categories.
1594 function question_category_options($courseid, $published = false, $only_editable = false) {
1595 global $CFG;
1597 // get sql fragment for published
1598 $publishsql="";
1599 if ($published) {
1600 $publishsql = " OR publish = 1";
1603 $categories = get_records_sql("
1604 SELECT cat.*, c.shortname AS coursename
1605 FROM {$CFG->prefix}question_categories cat, {$CFG->prefix}course c
1606 WHERE c.id = cat.course AND (cat.course = $courseid $publishsql)
1607 ORDER BY cat.parent, cat.sortorder, cat.name ASC");
1608 $categoriesarray = array();
1609 foreach ($categories as $category) {
1610 $cid = $category->id;
1611 $cname = question_category_coursename($category, $courseid);
1612 if ((!$only_editable) || has_capability('moodle/question:managecategory', get_context_instance(CONTEXT_COURSE, $category->course))) {
1613 $categoriesarray[$cid] = $cname;
1616 return $categoriesarray;
1620 * If the category is not from this course, and it is a published category,
1621 * then return the course category name with the course shortname appended in
1622 * brackets. Otherwise, just return the category name.
1624 function question_category_coursename($category, $courseid = 0) {
1625 $cname = (isset($category->indentedname)) ? $category->indentedname : $category->name;
1626 if ($category->course != $courseid && $category->publish) {
1627 if (!empty($category->coursename)) {
1628 $coursename = $category->coursename;
1629 } else {
1630 $coursename = get_field('course', 'shortname', 'id', $category->course);
1632 $cname .= " ($coursename)";
1634 return $cname;
1638 * Returns a comma separated list of ids of the category and all subcategories
1640 function question_categorylist($categoryid) {
1641 // returns a comma separated list of ids of the category and all subcategories
1642 $categorylist = $categoryid;
1643 if ($subcategories = get_records('question_categories', 'parent', $categoryid, 'sortorder ASC', 'id, id')) {
1644 foreach ($subcategories as $subcategory) {
1645 $categorylist .= ','. question_categorylist($subcategory->id);
1648 return $categorylist;
1652 * find and/or create the category described by a delimited list
1653 * e.g. tom/dick/harry
1654 * @param string catpath delimited category path
1655 * @param string delimiter path delimiting character
1656 * @param int courseid course to search for categories
1657 * @return mixed category object or null if fails
1659 function create_category_path( $catpath, $delimiter='/', $courseid=0 ) {
1660 $catpath = clean_param( $catpath,PARAM_PATH );
1661 $catnames = explode( $delimiter, $catpath );
1662 $parent = 0;
1663 $category = null;
1664 foreach ($catnames as $catname) {
1665 if ($category = get_record( 'question_categories', 'name', $catname, 'course', $courseid, 'parent', $parent )) {
1666 $parent = $category->id;
1668 else {
1669 // create the new category
1670 $category = new object;
1671 $category->course = $courseid;
1672 $category->name = $catname;
1673 $category->info = '';
1674 $category->publish = false;
1675 $category->parent = $parent;
1676 $category->sortorder = 999;
1677 $category->stamp = make_unique_id_code();
1678 if (!($id = insert_record( 'question_categories', $category ))) {
1679 error( "cannot create new category - $catname" );
1681 $category->id = $id;
1682 $parent = $id;
1685 return $category;
1689 * get the category as a path (e.g., tom/dick/harry)
1690 * @param int id the id of the most nested catgory
1691 * @param string delimiter the delimiter you want
1692 * @return string the path
1694 function get_category_path( $id, $delimiter='/' ) {
1695 $path = '';
1696 do {
1697 if (!$category = get_record( 'question_categories','id',$id )) {
1698 print_error( "Error reading category record - $id" );
1700 $name = $category->name;
1701 $id = $category->parent;
1702 if (!empty($path)) {
1703 $path = "{$name}{$delimiter}{$path}";
1705 else {
1706 $path = $name;
1708 } while ($id != 0);
1710 return $path;
1713 //===========================
1714 // Import/Export Functions
1715 //===========================
1718 * Get list of available import or export formats
1719 * @param string $type 'import' if import list, otherwise export list assumed
1720 * @return array sorted list of import/export formats available
1722 function get_import_export_formats( $type ) {
1724 global $CFG;
1725 $fileformats = get_list_of_plugins("question/format");
1727 $fileformatname=array();
1728 require_once( "{$CFG->dirroot}/question/format.php" );
1729 foreach ($fileformats as $key => $fileformat) {
1730 $format_file = $CFG->dirroot . "/question/format/$fileformat/format.php";
1731 if (file_exists( $format_file ) ) {
1732 require_once( $format_file );
1734 else {
1735 continue;
1737 $classname = "qformat_$fileformat";
1738 $format_class = new $classname();
1739 if ($type=='import') {
1740 $provided = $format_class->provide_import();
1742 else {
1743 $provided = $format_class->provide_export();
1745 if ($provided) {
1746 $formatname = get_string($fileformat, 'quiz');
1747 if ($formatname == "[[$fileformat]]") {
1748 $formatname = $fileformat; // Just use the raw folder name
1750 $fileformatnames[$fileformat] = $formatname;
1753 natcasesort($fileformatnames);
1755 return $fileformatnames;
1760 * Create default export filename
1762 * @return string default export filename
1763 * @param object $course
1764 * @param object $category
1766 function default_export_filename($course,$category) {
1767 //Take off some characters in the filename !!
1768 $takeoff = array(" ", ":", "/", "\\", "|");
1769 $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz")));
1770 //If non-translated, use "export"
1771 if (substr($export_word,0,1) == "[") {
1772 $export_word= "export";
1775 //Calculate the date format string
1776 $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz"));
1777 //If non-translated, use "%Y%m%d-%H%M"
1778 if (substr($export_date_format,0,1) == "[") {
1779 $export_date_format = "%%Y%%m%%d-%%H%%M";
1782 //Calculate the shortname
1783 $export_shortname = clean_filename($course->shortname);
1784 if (empty($export_shortname) or $export_shortname == '_' ) {
1785 $export_shortname = $course->id;
1788 //Calculate the category name
1789 $export_categoryname = clean_filename($category->name);
1791 //Calculate the final export filename
1792 //The export word
1793 $export_name = $export_word."-";
1794 //The shortname
1795 $export_name .= moodle_strtolower($export_shortname)."-";
1796 //The category name
1797 $export_name .= moodle_strtolower($export_categoryname)."-";
1798 //The date format
1799 $export_name .= userdate(time(),$export_date_format,99,false);
1800 //The extension - no extension, supplied by format
1801 // $export_name .= ".txt";
1803 return $export_name;