Fixed a grammatical error on error.php.
[moodle.git] / lib / questionlib.php
CommitLineData
516cf3eb 1<?php // $Id$
2/**
6b11a0e8 3 * Code for handling and processing questions
4 *
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}
9 *
10 * TODO: separate those functions which form part of the API
11 * from the helper functions.
12 *
6b11a0e8 13 * @author Martin Dougiamas and many others. This has recently been completely
14 * rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of
15 * the Serving Mathematics project
16 * {@link http://maths.york.ac.uk/serving_maths}
17 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
18 * @package question
19 */
20
21/// CONSTANTS ///////////////////////////////////
516cf3eb 22
e56a08dc 23/**#@+
6b11a0e8 24 * The different types of events that can create question states
25 */
f30bbcaf 26define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle
27define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used)
28define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated
29define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle.
30define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously
31define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded.
32define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle.
33define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked
34define('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.
bc2feba3 35define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher
e56a08dc 36/**#@-*/
37
38/**#@+
a2156789 39 * The core question types.
6b11a0e8 40 */
60407982 41define("SHORTANSWER", "shortanswer");
42define("TRUEFALSE", "truefalse");
43define("MULTICHOICE", "multichoice");
44define("RANDOM", "random");
45define("MATCH", "match");
46define("RANDOMSAMATCH", "randomsamatch");
47define("DESCRIPTION", "description");
48define("NUMERICAL", "numerical");
49define("MULTIANSWER", "multianswer");
50define("CALCULATED", "calculated");
60407982 51define("ESSAY", "essay");
e56a08dc 52/**#@-*/
53
6b11a0e8 54/**
55 * Constant determines the number of answer boxes supplied in the editing
56 * form for multiple choice and similar question types.
57 */
4f48fb42 58define("QUESTION_NUMANS", "10");
e56a08dc 59
271ffe3f 60/**
61 * Constant determines the number of answer boxes supplied in the editing
62 * form for multiple choice and similar question types to start with, with
63 * the option of adding QUESTION_NUMANS_ADD more answers.
64 */
65define("QUESTION_NUMANS_START", 3);
66
67/**
68 * Constant determines the number of answer boxes to add in the editing
69 * form for multiple choice and similar question types when the user presses
70 * 'add form fields button'.
71 */
72define("QUESTION_NUMANS_ADD", 3);
73
1b8a7434 74/**
75 * The options used when popping up a question preview window in Javascript.
76 */
7d87171b 77define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=700,height=540');
516cf3eb 78
6b11a0e8 79/**#@+
80 * Option flags for ->optionflags
81 * The options are read out via bitwise operation using these constants
82 */
83/**
84 * Whether the questions is to be run in adaptive mode. If this is not set then
85 * a question closes immediately after the first submission of responses. This
86 * is how question is Moodle always worked before version 1.5
87 */
88define('QUESTION_ADAPTIVE', 1);
89
3bee1ead 90/**
91 * options used in forms that move files.
92 *
93 */
94define('QUESTION_FILENOTHINGSELECTED', 0);
95define('QUESTION_FILEDONOTHING', 1);
96define('QUESTION_FILECOPY', 2);
97define('QUESTION_FILEMOVE', 3);
98define('QUESTION_FILEMOVELINKSONLY', 4);
99
6b11a0e8 100/**#@-*/
101
f02c6f01 102/// QTYPES INITIATION //////////////////
a2156789 103// These variables get initialised via calls to question_register_questiontype
104// as the question type classes are included.
e2ae84a2 105global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
516cf3eb 106/**
6b11a0e8 107 * Array holding question type objects
108 */
a2156789 109$QTYPES = array();
a2156789 110/**
111 * String in the format "'type1','type2'" that can be used in SQL clauses like
112 * "WHERE q.type IN ($QTYPE_MANUAL)".
113 */
271ffe3f 114$QTYPE_MANUAL = '';
a2156789 115/**
116 * String in the format "'type1','type2'" that can be used in SQL clauses like
117 * "WHERE q.type NOT IN ($QTYPE_EXCLUDE_FROM_RANDOM)".
118 */
119$QTYPE_EXCLUDE_FROM_RANDOM = '';
120
121/**
122 * Add a new question type to the various global arrays above.
271ffe3f 123 *
a2156789 124 * @param object $qtype An instance of the new question type class.
125 */
126function question_register_questiontype($qtype) {
e2ae84a2 127 global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
271ffe3f 128
a2156789 129 $name = $qtype->name();
130 $QTYPES[$name] = $qtype;
a2156789 131 if ($qtype->is_manual_graded()) {
132 if ($QTYPE_MANUAL) {
133 $QTYPE_MANUAL .= ',';
134 }
135 $QTYPE_MANUAL .= "'$name'";
136 }
137 if (!$qtype->is_usable_by_random()) {
138 if ($QTYPE_EXCLUDE_FROM_RANDOM) {
139 $QTYPE_EXCLUDE_FROM_RANDOM .= ',';
140 }
141 $QTYPE_EXCLUDE_FROM_RANDOM .= "'$name'";
142 }
143}
516cf3eb 144
643ec47d 145require_once("$CFG->dirroot/question/type/questiontype.php");
516cf3eb 146
a2156789 147// Load the questiontype.php file for each question type
271ffe3f 148// These files in turn call question_register_questiontype()
a2156789 149// with a new instance of each qtype class.
aaae75b0 150$qtypenames= get_list_of_plugins('question/type');
f02c6f01 151foreach($qtypenames as $qtypename) {
152 // Instanciates all plug-in question types
643ec47d 153 $qtypefilepath= "$CFG->dirroot/question/type/$qtypename/questiontype.php";
f02c6f01 154
155 // echo "Loading $qtypename<br/>"; // Uncomment for debugging
156 if (is_readable($qtypefilepath)) {
157 require_once($qtypefilepath);
516cf3eb 158 }
159}
160
e2ae84a2 161/**
162 * An array of question type names translated to the user's language, suitable for use when
163 * creating a drop-down menu of options.
164 *
165 * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
166 * The array returned will only hold the names of all the question types that the user should
167 * be able to create directly. Some internal question types like random questions are excluded.
168 *
169 * @return array an array of question type names translated to the user's language.
170 */
171function question_type_menu() {
172 global $QTYPES;
173 static $menu_options = null;
174 if (is_null($menu_options)) {
175 $menu_options = array();
176 foreach ($QTYPES as $name => $qtype) {
177 $menuname = $qtype->menu_name();
178 if ($menuname) {
179 $menu_options[$name] = $menuname;
180 }
181 }
182 }
183 return $menu_options;
184}
185
516cf3eb 186/// OTHER CLASSES /////////////////////////////////////////////////////////
187
188/**
6b11a0e8 189 * This holds the options that are set by the course module
190 */
516cf3eb 191class cmoptions {
192 /**
193 * Whether a new attempt should be based on the previous one. If true
194 * then a new attempt will start in a state where all responses are set
195 * to the last responses from the previous attempt.
196 */
197 var $attemptonlast = false;
198
199 /**
200 * Various option flags. The flags are accessed via bitwise operations
201 * using the constants defined in the CONSTANTS section above.
202 */
6b11a0e8 203 var $optionflags = QUESTION_ADAPTIVE;
516cf3eb 204
205 /**
206 * Determines whether in the calculation of the score for a question
207 * penalties for earlier wrong responses within the same attempt will
208 * be subtracted.
209 */
210 var $penaltyscheme = true;
211
212 /**
213 * The maximum time the user is allowed to answer the questions withing
214 * an attempt. This is measured in minutes so needs to be multiplied by
215 * 60 before compared to timestamps. If set to 0 no timelimit will be applied
216 */
217 var $timelimit = 0;
218
219 /**
220 * Timestamp for the closing time. Responses submitted after this time will
221 * be saved but no credit will be given for them.
222 */
223 var $timeclose = 9999999999;
224
225 /**
226 * The id of the course from withing which the question is currently being used
227 */
228 var $course = SITEID;
229
230 /**
231 * Whether the answers in a multiple choice question should be randomly
232 * shuffled when a new attempt is started.
233 */
c4c11af8 234 var $shuffleanswers = true;
516cf3eb 235
236 /**
237 * The number of decimals to be shown when scores are printed
238 */
239 var $decimalpoints = 2;
516cf3eb 240}
241
242
516cf3eb 243/// FUNCTIONS //////////////////////////////////////////////////////
244
90c3f310 245/**
f67172b6 246 * Returns an array of names of activity modules that use this question
90c3f310 247 *
f67172b6 248 * @param object $questionid
249 * @return array of strings
90c3f310 250 */
f67172b6 251function question_list_instances($questionid) {
7453df70 252 global $CFG;
90c3f310 253 $instances = array();
254 $modules = get_records('modules');
255 foreach ($modules as $module) {
7453df70 256 $fullmod = $CFG->dirroot . '/mod/' . $module->name;
257 if (file_exists($fullmod . '/lib.php')) {
258 include_once($fullmod . '/lib.php');
259 $fn = $module->name.'_question_list_instances';
260 if (function_exists($fn)) {
261 $instances = $instances + $fn($questionid);
262 }
90c3f310 263 }
264 }
265 return $instances;
266}
516cf3eb 267
271ffe3f 268/**
e9de4366 269 * Returns list of 'allowed' grades for grade selection
270 * formatted suitably for dropdown box function
271 * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
272 */
273function get_grade_options() {
274 // define basic array of grades
275 $grades = array(
276 1,
277 0.9,
278 0.8,
279 0.75,
280 0.70,
281 0.66666,
282 0.60,
283 0.50,
284 0.40,
285 0.33333,
286 0.30,
287 0.25,
288 0.20,
289 0.16666,
290 0.142857,
291 0.125,
292 0.11111,
293 0.10,
294 0.05,
295 0);
296
297 // iterate through grades generating full range of options
298 $gradeoptionsfull = array();
299 $gradeoptions = array();
300 foreach ($grades as $grade) {
301 $percentage = 100 * $grade;
302 $neggrade = -$grade;
303 $gradeoptions["$grade"] = "$percentage %";
304 $gradeoptionsfull["$grade"] = "$percentage %";
305 $gradeoptionsfull["$neggrade"] = -$percentage." %";
306 }
307 $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
308
309 // sort lists
310 arsort($gradeoptions, SORT_NUMERIC);
311 arsort($gradeoptionsfull, SORT_NUMERIC);
312
313 // construct return object
314 $grades = new stdClass;
315 $grades->gradeoptions = $gradeoptions;
316 $grades->gradeoptionsfull = $gradeoptionsfull;
317
318 return $grades;
319}
320
8511669c 321/**
322 * match grade options
323 * if no match return error or match nearest
324 * @param array $gradeoptionsfull list of valid options
325 * @param int $grade grade to be tested
326 * @param string $matchgrades 'error' or 'nearest'
327 * @return mixed either 'fixed' value or false if erro
328 */
329function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
330 // if we just need an error...
331 if ($matchgrades=='error') {
332 foreach($gradeoptionsfull as $value => $option) {
2784b982 333 // slightly fuzzy test, never check floats for equality :-)
334 if (abs($grade-$value)<0.00001) {
8511669c 335 return $grade;
336 }
337 }
338 // didn't find a match so that's an error
339 return false;
340 }
341 // work out nearest value
342 else if ($matchgrades=='nearest') {
343 $hownear = array();
344 foreach($gradeoptionsfull as $value => $option) {
345 if ($grade==$value) {
346 return $grade;
347 }
348 $hownear[ $value ] = abs( $grade - $value );
349 }
350 // reverse sort list of deltas and grab the last (smallest)
351 asort( $hownear, SORT_NUMERIC );
352 reset( $hownear );
353 return key( $hownear );
354 }
355 else {
356 return false;
271ffe3f 357 }
8511669c 358}
359
f67172b6 360/**
361 * Tests whether a category is in use by any activity module
362 *
363 * @return boolean
271ffe3f 364 * @param integer $categoryid
f67172b6 365 * @param boolean $recursive Whether to examine category children recursively
366 */
367function question_category_isused($categoryid, $recursive = false) {
368
369 //Look at each question in the category
370 if ($questions = get_records('question', 'category', $categoryid)) {
371 foreach ($questions as $question) {
372 if (count(question_list_instances($question->id))) {
373 return true;
374 }
375 }
376 }
377
378 //Look under child categories recursively
379 if ($recursive) {
380 if ($children = get_records('question_categories', 'parent', $categoryid)) {
381 foreach ($children as $child) {
382 if (question_category_isused($child->id, $recursive)) {
383 return true;
384 }
385 }
386 }
387 }
388
389 return false;
390}
391
0429cd86 392/**
393 * Deletes all data associated to an attempt from the database
394 *
93eb0ea3 395 * @param integer $attemptid The id of the attempt being deleted
0429cd86 396 */
397function delete_attempt($attemptid) {
398 global $QTYPES;
399
400 $states = get_records('question_states', 'attempt', $attemptid);
9523cbfc 401 if ($states) {
402 $stateslist = implode(',', array_keys($states));
3bee1ead 403
9523cbfc 404 // delete question-type specific data
405 foreach ($QTYPES as $qtype) {
406 $qtype->delete_states($stateslist);
407 }
0429cd86 408 }
409
410 // delete entries from all other question tables
411 // It is important that this is done only after calling the questiontype functions
412 delete_records("question_states", "attempt", $attemptid);
413 delete_records("question_sessions", "attemptid", $attemptid);
36be25f6 414 delete_records("question_attempts", "id", $attemptid);
0429cd86 415}
f67172b6 416
516cf3eb 417/**
6b11a0e8 418 * Deletes question and all associated data from the database
419 *
90c3f310 420 * It will not delete a question if it is used by an activity module
6b11a0e8 421 * @param object $question The question being deleted
422 */
90c3f310 423function delete_question($questionid) {
f02c6f01 424 global $QTYPES;
271ffe3f 425
90c3f310 426 // Do not delete a question if it is used by an activity module
f67172b6 427 if (count(question_list_instances($questionid))) {
90c3f310 428 return;
429 }
430
431 // delete questiontype-specific data
3bee1ead 432 $question = get_record('question', 'id', $questionid);
433 question_require_capability_on($question, 'edit');
434 if ($question) {
cf60a28a 435 if (isset($QTYPES[$question->qtype])) {
436 $QTYPES[$question->qtype]->delete_question($questionid);
437 }
438 } else {
439 echo "Question with id $questionid does not exist.<br />";
516cf3eb 440 }
90c3f310 441
5cb9076a 442 if ($states = get_records('question_states', 'question', $questionid)) {
443 $stateslist = implode(',', array_keys($states));
271ffe3f 444
5cb9076a 445 // delete questiontype-specific data
446 foreach ($QTYPES as $qtype) {
447 $qtype->delete_states($stateslist);
448 }
0429cd86 449 }
450
90c3f310 451 // delete entries from all other question tables
452 // It is important that this is done only after calling the questiontype functions
453 delete_records("question_answers", "question", $questionid);
454 delete_records("question_states", "question", $questionid);
455 delete_records("question_sessions", "questionid", $questionid);
456
457 // Now recursively delete all child questions
458 if ($children = get_records('question', 'parent', $questionid)) {
516cf3eb 459 foreach ($children as $child) {
1d723a16 460 if ($child->id != $questionid) {
461 delete_question($child->id);
462 }
516cf3eb 463 }
464 }
271ffe3f 465
90c3f310 466 // Finally delete the question record itself
467 delete_records('question', 'id', $questionid);
468
469 return;
516cf3eb 470}
471
f67172b6 472/**
3bee1ead 473 * All question categories and their questions are deleted for this course.
f67172b6 474 *
3bee1ead 475 * @param object $mod an object representing the activity
f67172b6 476 * @param boolean $feedback to specify if the process must output a summary of its work
477 * @return boolean
478 */
479function question_delete_course($course, $feedback=true) {
f67172b6 480 //To store feedback to be showed at the end of the process
481 $feedbackdata = array();
482
483 //Cache some strings
f67172b6 484 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
3bee1ead 485 $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
486 $categoriescourse = get_records('question_categories', 'contextid', $coursecontext->id, 'parent', 'id, parent, name');
f67172b6 487
3bee1ead 488 if ($categoriescourse) {
f67172b6 489
490 //Sort categories following their tree (parent-child) relationships
3bee1ead 491 //this will make the feedback more readable
492 $categoriescourse = sort_categories_by_tree($categoriescourse);
493
494 foreach ($categoriescourse as $category) {
495
496 //Delete it completely (questions and category itself)
497 //deleting questions
498 if ($questions = get_records("question", "category", $category->id)) {
499 foreach ($questions as $question) {
500 delete_question($question->id);
f67172b6 501 }
3bee1ead 502 delete_records("question", "category", $category->id);
503 }
504 //delete the category
505 delete_records('question_categories', 'id', $category->id);
f67172b6 506
3bee1ead 507 //Fill feedback
508 $feedbackdata[] = array($category->name, $strcatdeleted);
509 }
510 //Inform about changes performed if feedback is enabled
511 if ($feedback) {
512 $table = new stdClass;
513 $table->head = array(get_string('category','quiz'), get_string('action'));
514 $table->data = $feedbackdata;
515 print_table($table);
516 }
517 }
518 return true;
519}
f67172b6 520
3bee1ead 521/**
522 * All question categories and their questions are deleted for this activity.
523 *
524 * @param object $cm the course module object representing the activity
525 * @param boolean $feedback to specify if the process must output a summary of its work
526 * @return boolean
527 */
528function question_delete_activity($cm, $feedback=true) {
529 //To store feedback to be showed at the end of the process
530 $feedbackdata = array();
f67172b6 531
3bee1ead 532 //Cache some strings
533 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
534 $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
535 if ($categoriesmods = get_records('question_categories', 'contextid', $modcontext->id, 'parent', 'id, parent, name')){
536 //Sort categories following their tree (parent-child) relationships
537 //this will make the feedback more readable
538 $categoriesmods = sort_categories_by_tree($categoriesmods);
f67172b6 539
3bee1ead 540 foreach ($categoriesmods as $category) {
f67172b6 541
3bee1ead 542 //Delete it completely (questions and category itself)
543 //deleting questions
544 if ($questions = get_records("question", "category", $category->id)) {
545 foreach ($questions as $question) {
546 delete_question($question->id);
547 }
548 delete_records("question", "category", $category->id);
f67172b6 549 }
3bee1ead 550 //delete the category
551 delete_records('question_categories', 'id', $category->id);
552
553 //Fill feedback
554 $feedbackdata[] = array($category->name, $strcatdeleted);
f67172b6 555 }
556 //Inform about changes performed if feedback is enabled
557 if ($feedback) {
d7444d44 558 $table = new stdClass;
f67172b6 559 $table->head = array(get_string('category','quiz'), get_string('action'));
560 $table->data = $feedbackdata;
561 print_table($table);
562 }
563 }
564 return true;
565}
7fb1b88d 566
567/**
568 * This function should be considered private to the question bank, it is called from
569 * question/editlib.php question/contextmoveq.php and a few similar places to to the work of
570 * acutally moving questions and associated data. However, callers of this function also have to
571 * do other work, which is why you should not call this method directly from outside the questionbank.
572 *
573 * @param string $questionids a comma-separated list of question ids.
574 * @param integer $newcategory the id of the category to move to.
575 */
576function question_move_questions_to_category($questionids, $newcategory) {
577 $result = true;
578
579 // Move the questions themselves.
580 $result = $result && set_field_select('question', 'category', $newcategory, "id IN ($questionids)");
581
582 // Move any subquestions belonging to them.
583 $result = $result && set_field_select('question', 'category', $newcategory, "parent IN ($questionids)");
584
585 // TODO Deal with datasets.
586
587 return $result;
588}
589
3bee1ead 590/**
591 * @param array $row tab objects
592 * @param question_edit_contexts $contexts object representing contexts available from this context
593 * @param string $querystring to append to urls
594 * */
595function questionbank_navigation_tabs(&$row, $contexts, $querystring) {
596 global $CFG, $QUESTION_EDITTABCAPS;
597 $tabs = array(
598 'questions' =>array("$CFG->wwwroot/question/edit.php?$querystring", get_string('questions', 'quiz'), get_string('editquestions', 'quiz')),
599 'categories' =>array("$CFG->wwwroot/question/category.php?$querystring", get_string('categories', 'quiz'), get_string('editqcats', 'quiz')),
600 'import' =>array("$CFG->wwwroot/question/import.php?$querystring", get_string('import', 'quiz'), get_string('importquestions', 'quiz')),
601 'export' =>array("$CFG->wwwroot/question/export.php?$querystring", get_string('export', 'quiz'), get_string('exportquestions', 'quiz')));
602 foreach ($tabs as $tabname => $tabparams){
603 if ($contexts->have_one_edit_tab_cap($tabname)) {
604 $row[] = new tabobject($tabname, $tabparams[0], $tabparams[1], $tabparams[2]);
605 }
d187f660 606 }
607}
608
5a2a5331 609/**
610 * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
611 * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
612 * read the code below to see how the SQL is assembled.
613 *
614 * @param string $questionlist list of comma-separated question ids.
615 * @param string $extrafields
616 * @param string $join
617 *
618 * @return mixed array of question objects on success, a string error message on failure.
619 */
620function question_load_questions($questionlist, $extrafields = '', $join = '') {
621 global $CFG;
622 if ($join) {
623 $join = ' JOIN ' . $CFG->prefix . $join;
624 }
625 if ($extrafields) {
626 $extrafields = ', ' . $extrafields;
627 }
628 $sql = 'SELECT q.*' . $extrafields . ' FROM ' . $CFG->prefix . 'question q' . $join .
629 ' WHERE q.id IN (' . $questionlist . ')';
630
631 // Load the questions
632 if (!$questions = get_records_sql($sql)) {
633 return 'Could not load questions.';
634 }
635
636 // Load the question type specific information
637 if (!get_question_options($questions)) {
638 return 'Could not load the question options';
639 }
640
641 return $questions;
642}
643
516cf3eb 644/**
d7444d44 645 * Private function to factor common code out of get_question_options().
271ffe3f 646 *
d7444d44 647 * @param object $question the question to tidy.
271ffe3f 648 * @return boolean true if successful, else false.
d7444d44 649 */
650function _tidy_question(&$question) {
f02c6f01 651 global $QTYPES;
d7444d44 652 if (!array_key_exists($question->qtype, $QTYPES)) {
653 $question->qtype = 'missingtype';
654 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
655 }
656 $question->name_prefix = question_make_name_prefix($question->id);
657 return $QTYPES[$question->qtype]->get_question_options($question);
658}
516cf3eb 659
d7444d44 660/**
661 * Updates the question objects with question type specific
662 * information by calling {@link get_question_options()}
663 *
664 * Can be called either with an array of question objects or with a single
665 * question object.
271ffe3f 666 *
d7444d44 667 * @param mixed $questions Either an array of question objects to be updated
668 * or just a single question object
669 * @return bool Indicates success or failure.
670 */
671function get_question_options(&$questions) {
516cf3eb 672 if (is_array($questions)) { // deal with an array of questions
d7444d44 673 foreach ($questions as $i => $notused) {
674 if (!_tidy_question($questions[$i])) {
516cf3eb 675 return false;
d7444d44 676 }
516cf3eb 677 }
678 return true;
679 } else { // deal with single question
d7444d44 680 return _tidy_question($questions);
516cf3eb 681 }
682}
683
684/**
685* Loads the most recent state of each question session from the database
686* or create new one.
687*
688* For each question the most recent session state for the current attempt
4f48fb42 689* is loaded from the question_states table and the question type specific data and
690* responses are added by calling {@link restore_question_state()} which in turn
516cf3eb 691* calls {@link restore_session_and_responses()} for each question.
692* If no states exist for the question instance an empty state object is
693* created representing the start of a session and empty question
694* type specific information and responses are created by calling
695* {@link create_session_and_responses()}.
516cf3eb 696*
697* @return array An array of state objects representing the most recent
698* states of the question sessions.
699* @param array $questions The questions for which sessions are to be restored or
700* created.
701* @param object $cmoptions
702* @param object $attempt The attempt for which the question sessions are
703* to be restored or created.
46e910c7 704* @param mixed either the id of a previous attempt, if this attmpt is
705* building on a previous one, or false for a clean attempt.
516cf3eb 706*/
46e910c7 707function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) {
f02c6f01 708 global $CFG, $QTYPES;
516cf3eb 709
710 // get the question ids
711 $ids = array_keys($questions);
712 $questionlist = implode(',', $ids);
713
714 // The question field must be listed first so that it is used as the
715 // array index in the array returned by get_records_sql
3e3e5a40 716 $statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment';
516cf3eb 717 // Load the newest states for the questions
718 $sql = "SELECT $statefields".
4f48fb42 719 " FROM {$CFG->prefix}question_states s,".
516cf3eb 720 " {$CFG->prefix}question_sessions n".
721 " WHERE s.id = n.newest".
722 " AND n.attemptid = '$attempt->uniqueid'".
723 " AND n.questionid IN ($questionlist)";
724 $states = get_records_sql($sql);
725
726 // Load the newest graded states for the questions
727 $sql = "SELECT $statefields".
4f48fb42 728 " FROM {$CFG->prefix}question_states s,".
516cf3eb 729 " {$CFG->prefix}question_sessions n".
730 " WHERE s.id = n.newgraded".
731 " AND n.attemptid = '$attempt->uniqueid'".
732 " AND n.questionid IN ($questionlist)";
733 $gradedstates = get_records_sql($sql);
734
735 // loop through all questions and set the last_graded states
736 foreach ($ids as $i) {
737 if (isset($states[$i])) {
4f48fb42 738 restore_question_state($questions[$i], $states[$i]);
516cf3eb 739 if (isset($gradedstates[$i])) {
4f48fb42 740 restore_question_state($questions[$i], $gradedstates[$i]);
516cf3eb 741 $states[$i]->last_graded = $gradedstates[$i];
742 } else {
743 $states[$i]->last_graded = clone($states[$i]);
516cf3eb 744 }
745 } else {
fb708c11 746 // If the new attempt is to be based on a previous attempt get it and clean things
747 // Having lastattemptid filled implies that (should we double check?):
748 // $attempt->attempt > 1 and $cmoptions->attemptonlast and !$attempt->preview
749 if ($lastattemptid) {
750 // find the responses from the previous attempt and save them to the new session
751
752 // Load the last graded state for the question
753 $statefields = 'n.questionid as question, s.*, n.sumpenalty';
754 $sql = "SELECT $statefields".
755 " FROM {$CFG->prefix}question_states s,".
756 " {$CFG->prefix}question_sessions n".
757 " WHERE s.id = n.newgraded".
758 " AND n.attemptid = '$lastattemptid'".
759 " AND n.questionid = '$i'";
760 if (!$laststate = get_record_sql($sql)) {
761 // Only restore previous responses that have been graded
762 continue;
763 }
764 // Restore the state so that the responses will be restored
765 restore_question_state($questions[$i], $laststate);
d402153a 766 $states[$i] = clone($laststate);
8ad877b6 767 unset($states[$i]->id);
fb708c11 768 } else {
d402153a 769 // create a new empty state
770 $states[$i] = new object;
771 $states[$i]->question = $i;
772 $states[$i]->responses = array('' => '');
773 $states[$i]->raw_grade = 0;
fb708c11 774 }
775
776 // now fill/overide initial values
d23e3e11 777 $states[$i]->attempt = $attempt->uniqueid;
d23e3e11 778 $states[$i]->seq_number = 0;
779 $states[$i]->timestamp = $attempt->timestart;
780 $states[$i]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
781 $states[$i]->grade = 0;
d23e3e11 782 $states[$i]->penalty = 0;
783 $states[$i]->sumpenalty = 0;
3e3e5a40 784 $states[$i]->manualcomment = '';
fb708c11 785
d23e3e11 786 // Prevent further changes to the session from incrementing the
787 // sequence number
788 $states[$i]->changed = true;
789
fb708c11 790 if ($lastattemptid) {
791 // prepare the previous responses for new processing
792 $action = new stdClass;
793 $action->responses = $laststate->responses;
794 $action->timestamp = $laststate->timestamp;
795 $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631
796
797 // Process these responses ...
798 question_process_responses($questions[$i], $states[$i], $action, $cmoptions, $attempt);
799
800 // Fix for Bug #5506: When each attempt is built on the last one,
3bee1ead 801 // preserve the options from any previous attempt.
fb708c11 802 if ( isset($laststate->options) ) {
803 $states[$i]->options = $laststate->options;
804 }
805 } else {
806 // Create the empty question type specific information
807 if (!$QTYPES[$questions[$i]->qtype]->create_session_and_responses(
808 $questions[$i], $states[$i], $cmoptions, $attempt)) {
809 return false;
810 }
516cf3eb 811 }
d23e3e11 812 $states[$i]->last_graded = clone($states[$i]);
516cf3eb 813 }
814 }
815 return $states;
816}
817
818
819/**
820* Creates the run-time fields for the states
821*
822* Extends the state objects for a question by calling
823* {@link restore_session_and_responses()}
516cf3eb 824* @param object $question The question for which the state is needed
865b7534 825* @param object $state The state as loaded from the database
826* @return boolean Represents success or failure
516cf3eb 827*/
4f48fb42 828function restore_question_state(&$question, &$state) {
f02c6f01 829 global $QTYPES;
516cf3eb 830
831 // initialise response to the value in the answer field
865b7534 832 $state->responses = array('' => addslashes($state->answer));
516cf3eb 833 unset($state->answer);
3e3e5a40 834 $state->manualcomment = isset($state->manualcomment) ? addslashes($state->manualcomment) : '';
516cf3eb 835
836 // Set the changed field to false; any code which changes the
837 // question session must set this to true and must increment
4f48fb42 838 // ->seq_number. The save_question_session
516cf3eb 839 // function will save the new state object to the database if the field is
840 // set to true.
841 $state->changed = false;
842
843 // Load the question type specific data
f02c6f01 844 return $QTYPES[$question->qtype]
865b7534 845 ->restore_session_and_responses($question, $state);
516cf3eb 846
847}
848
849/**
850* Saves the current state of the question session to the database
851*
852* The state object representing the current state of the session for the
4f48fb42 853* question is saved to the question_states table with ->responses[''] saved
516cf3eb 854* to the answer field of the database table. The information in the
855* question_sessions table is updated.
856* The question type specific data is then saved.
bc2feba3 857* @return mixed The id of the saved or updated state or false
516cf3eb 858* @param object $question The question for which session is to be saved.
859* @param object $state The state information to be saved. In particular the
860* most recent responses are in ->responses. The object
861* is updated to hold the new ->id.
862*/
4f48fb42 863function save_question_session(&$question, &$state) {
f02c6f01 864 global $QTYPES;
516cf3eb 865 // Check if the state has changed
866 if (!$state->changed && isset($state->id)) {
bc2feba3 867 return $state->id;
516cf3eb 868 }
869 // Set the legacy answer field
870 $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
871
872 // Save the state
36be25f6 873 if (!empty($state->update)) { // this forces the old state record to be overwritten
4f48fb42 874 update_record('question_states', $state);
516cf3eb 875 } else {
4f48fb42 876 if (!$state->id = insert_record('question_states', $state)) {
516cf3eb 877 unset($state->id);
878 unset($state->answer);
879 return false;
880 }
b6e907a2 881 }
516cf3eb 882
b6e907a2 883 // create or update the session
2e9b6d15 884 if (!$session = get_record('question_sessions', 'attemptid',
865b7534 885 $state->attempt, 'questionid', $question->id)) {
2e9b6d15 886 $session->attemptid = $state->attempt;
887 $session->questionid = $question->id;
888 $session->newest = $state->id;
889 // The following may seem weird, but the newgraded field needs to be set
890 // already even if there is no graded state yet.
891 $session->newgraded = $state->id;
892 $session->sumpenalty = $state->sumpenalty;
3e3e5a40 893 $session->manualcomment = $state->manualcomment;
2e9b6d15 894 if (!insert_record('question_sessions', $session)) {
5a2a5331 895 print_error('Could not insert entry in question_sessions');
516cf3eb 896 }
b6e907a2 897 } else {
2e9b6d15 898 $session->newest = $state->id;
899 if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
900 // this state is graded or newly opened, so it goes into the lastgraded field as well
901 $session->newgraded = $state->id;
902 $session->sumpenalty = $state->sumpenalty;
3e3e5a40 903 $session->manualcomment = $state->manualcomment;
ef822868 904 } else {
3e3e5a40 905 $session->manualcomment = addslashes($session->manualcomment);
516cf3eb 906 }
2e9b6d15 907 update_record('question_sessions', $session);
516cf3eb 908 }
909
910 unset($state->answer);
911
912 // Save the question type specific state information and responses
f02c6f01 913 if (!$QTYPES[$question->qtype]->save_session_and_responses(
516cf3eb 914 $question, $state)) {
915 return false;
916 }
917 // Reset the changed flag
918 $state->changed = false;
bc2feba3 919 return $state->id;
516cf3eb 920}
921
922/**
923* Determines whether a state has been graded by looking at the event field
924*
925* @return boolean true if the state has been graded
926* @param object $state
927*/
4f48fb42 928function question_state_is_graded($state) {
bc2feba3 929 return ($state->event == QUESTION_EVENTGRADE
930 or $state->event == QUESTION_EVENTCLOSEANDGRADE
931 or $state->event == QUESTION_EVENTMANUALGRADE);
f30bbcaf 932}
933
934/**
935* Determines whether a state has been closed by looking at the event field
936*
937* @return boolean true if the state has been closed
938* @param object $state
939*/
940function question_state_is_closed($state) {
b6e907a2 941 return ($state->event == QUESTION_EVENTCLOSE
942 or $state->event == QUESTION_EVENTCLOSEANDGRADE
943 or $state->event == QUESTION_EVENTMANUALGRADE);
516cf3eb 944}
945
946
947/**
4dca7e51 948 * Extracts responses from submitted form
949 *
950 * This can extract the responses given to one or several questions present on a page
951 * It returns an array with one entry for each question, indexed by question id
952 * Each entry is an object with the properties
953 * ->event The event that has triggered the submission. This is determined by which button
954 * the user has pressed.
955 * ->responses An array holding the responses to an individual question, indexed by the
956 * name of the corresponding form element.
957 * ->timestamp A unix timestamp
958 * @return array array of action objects, indexed by question ids.
959 * @param array $questions an array containing at least all questions that are used on the form
960 * @param array $formdata the data submitted by the form on the question page
961 * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted
962 */
963function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
516cf3eb 964
4dca7e51 965 $time = time();
516cf3eb 966 $actions = array();
4dca7e51 967 foreach ($formdata as $key => $response) {
516cf3eb 968 // Get the question id from the response name
4f48fb42 969 if (false !== ($quid = question_get_id_from_name_prefix($key))) {
516cf3eb 970 // check if this is a valid id
971 if (!isset($questions[$quid])) {
5a2a5331 972 print_error('Form contained question that is not in questionids');
516cf3eb 973 }
974
975 // Remove the name prefix from the name
976 //decrypt trying
977 $key = substr($key, strlen($questions[$quid]->name_prefix));
978 if (false === $key) {
979 $key = '';
980 }
981 // Check for question validate and mark buttons & set events
982 if ($key === 'validate') {
4f48fb42 983 $actions[$quid]->event = QUESTION_EVENTVALIDATE;
4dca7e51 984 } else if ($key === 'submit') {
f30bbcaf 985 $actions[$quid]->event = QUESTION_EVENTSUBMIT;
516cf3eb 986 } else {
987 $actions[$quid]->event = $defaultevent;
988 }
989
990 // Update the state with the new response
991 $actions[$quid]->responses[$key] = $response;
271ffe3f 992
4dca7e51 993 // Set the timestamp
994 $actions[$quid]->timestamp = $time;
516cf3eb 995 }
996 }
3ebdddf7 997 foreach ($actions as $quid => $notused) {
998 ksort($actions[$quid]->responses);
999 }
516cf3eb 1000 return $actions;
1001}
1002
1003
bc75cc52 1004/**
1005 * Returns the html for question feedback image.
1006 * @param float $fraction value representing the correctness of the user's
1007 * response to a question.
1008 * @param boolean $selected whether or not the answer is the one that the
1009 * user picked.
1010 * @return string
1011 */
1012function question_get_feedback_image($fraction, $selected=true) {
1013
1014 global $CFG;
1015
1016 if ($fraction >= 1.0) {
1017 if ($selected) {
1018 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_green_big.gif" '.
0d905d9f 1019 'alt="'.get_string('correct', 'quiz').'" class="icon" />';
bc75cc52 1020 } else {
1021 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_green_small.gif" '.
0d905d9f 1022 'alt="'.get_string('correct', 'quiz').'" class="icon" />';
bc75cc52 1023 }
1024 } else if ($fraction > 0.0 && $fraction < 1.0) {
1025 if ($selected) {
1026 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_amber_big.gif" '.
0d905d9f 1027 'alt="'.get_string('partiallycorrect', 'quiz').'" class="icon" />';
bc75cc52 1028 } else {
1029 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_amber_small.gif" '.
0d905d9f 1030 'alt="'.get_string('partiallycorrect', 'quiz').'" class="icon" />';
bc75cc52 1031 }
1032 } else {
1033 if ($selected) {
1034 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/cross_red_big.gif" '.
0d905d9f 1035 'alt="'.get_string('incorrect', 'quiz').'" class="icon" />';
bc75cc52 1036 } else {
1037 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/cross_red_small.gif" '.
0d905d9f 1038 'alt="'.get_string('incorrect', 'quiz').'" class="icon" />';
bc75cc52 1039 }
1040 }
1041 return $feedbackimg;
1042}
1043
1044
1045/**
1046 * Returns the class name for question feedback.
1047 * @param float $fraction value representing the correctness of the user's
1048 * response to a question.
1049 * @return string
1050 */
1051function question_get_feedback_class($fraction) {
1052
1053 global $CFG;
1054
1055 if ($fraction >= 1.0) {
1056 $class = 'correct';
1057 } else if ($fraction > 0.0 && $fraction < 1.0) {
1058 $class = 'partiallycorrect';
1059 } else {
1060 $class = 'incorrect';
1061 }
1062 return $class;
1063}
1064
516cf3eb 1065
1066/**
1067* For a given question in an attempt we walk the complete history of states
1068* and recalculate the grades as we go along.
1069*
1070* This is used when a question is changed and old student
1071* responses need to be marked with the new version of a question.
1072*
4f48fb42 1073* TODO: Make sure this is not quiz-specific
1074*
0a5b58af 1075* @return boolean Indicates whether the grade has changed
516cf3eb 1076* @param object $question A question object
1077* @param object $attempt The attempt, in which the question needs to be regraded.
1078* @param object $cmoptions
1079* @param boolean $verbose Optional. Whether to print progress information or not.
1080*/
4f48fb42 1081function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false) {
516cf3eb 1082
1083 // load all states for this question in this attempt, ordered in sequence
4f48fb42 1084 if ($states = get_records_select('question_states',
865b7534 1085 "attempt = '{$attempt->uniqueid}' AND question = '{$question->id}'",
1086 'seq_number ASC')) {
516cf3eb 1087 $states = array_values($states);
1088
1089 // Subtract the grade for the latest state from $attempt->sumgrades to get the
271ffe3f 1090 // sumgrades for the attempt without this question.
516cf3eb 1091 $attempt->sumgrades -= $states[count($states)-1]->grade;
1092
1093 // Initialise the replaystate
1094 $state = clone($states[0]);
3e3e5a40 1095 $state->manualcomment = get_field('question_sessions', 'manualcomment', 'attemptid',
82b5d7cd 1096 $attempt->uniqueid, 'questionid', $question->id);
4f48fb42 1097 restore_question_state($question, $state);
516cf3eb 1098 $state->sumpenalty = 0.0;
1099 $replaystate = clone($state);
1100 $replaystate->last_graded = $state;
1101
0a5b58af 1102 $changed = false;
516cf3eb 1103 for($j = 1; $j < count($states); $j++) {
4f48fb42 1104 restore_question_state($question, $states[$j]);
516cf3eb 1105 $action = new stdClass;
1106 $action->responses = $states[$j]->responses;
1107 $action->timestamp = $states[$j]->timestamp;
1108
f30bbcaf 1109 // Change event to submit so that it will be reprocessed
0a5b58af 1110 if (QUESTION_EVENTCLOSE == $states[$j]->event
865b7534 1111 or QUESTION_EVENTGRADE == $states[$j]->event
1112 or QUESTION_EVENTCLOSEANDGRADE == $states[$j]->event) {
f30bbcaf 1113 $action->event = QUESTION_EVENTSUBMIT;
516cf3eb 1114
1115 // By default take the event that was saved in the database
1116 } else {
1117 $action->event = $states[$j]->event;
1118 }
36be25f6 1119
5e60643e 1120 if ($action->event == QUESTION_EVENTMANUALGRADE) {
865b7534 1121 question_process_comment($question, $replaystate, $attempt,
3e3e5a40 1122 $replaystate->manualcomment, $states[$j]->grade);
36be25f6 1123 } else {
1124
1125 // Reprocess (regrade) responses
865b7534 1126 if (!question_process_responses($question, $replaystate,
1127 $action, $cmoptions, $attempt)) {
36be25f6 1128 $verbose && notify("Couldn't regrade state #{$state->id}!");
1129 }
516cf3eb 1130 }
1131
1132 // We need rounding here because grades in the DB get truncated
1133 // e.g. 0.33333 != 0.3333333, but we want them to be equal here
0a5b58af 1134 if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
865b7534 1135 or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
1136 or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
0a5b58af 1137 $changed = true;
516cf3eb 1138 }
1139
1140 $replaystate->id = $states[$j]->id;
271ffe3f 1141 $replaystate->changed = true;
d23e3e11 1142 $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
4f48fb42 1143 save_question_session($question, $replaystate);
516cf3eb 1144 }
0a5b58af 1145 if ($changed) {
1780811d 1146 // TODO, call a method in quiz to do this, where 'quiz' comes from
1147 // the question_attempts table.
0a5b58af 1148 update_record('quiz_attempts', $attempt);
516cf3eb 1149 }
1150
0a5b58af 1151 return $changed;
516cf3eb 1152 }
0a5b58af 1153 return false;
516cf3eb 1154}
1155
1156/**
1157* Processes an array of student responses, grading and saving them as appropriate
1158*
1159* @return boolean Indicates success/failure
1160* @param object $question Full question object, passed by reference
1161* @param object $state Full state object, passed by reference
1162* @param object $action object with the fields ->responses which
1163* is an array holding the student responses,
4f48fb42 1164* ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
516cf3eb 1165* and ->timestamp which is a timestamp from when the responses
1166* were submitted by the student.
1167* @param object $cmoptions
1168* @param object $attempt The attempt is passed by reference so that
1169* during grading its ->sumgrades field can be updated
1170*/
4f48fb42 1171function question_process_responses(&$question, &$state, $action, $cmoptions, &$attempt) {
f02c6f01 1172 global $QTYPES;
516cf3eb 1173
1174 // if no responses are set initialise to empty response
1175 if (!isset($action->responses)) {
1176 $action->responses = array('' => '');
1177 }
1178
1179 // make sure these are gone!
755bddf1 1180 unset($action->responses['submit'], $action->responses['validate']);
516cf3eb 1181
1182 // Check the question session is still open
f30bbcaf 1183 if (question_state_is_closed($state)) {
516cf3eb 1184 return true;
1185 }
f30bbcaf 1186
516cf3eb 1187 // If $action->event is not set that implies saving
1188 if (! isset($action->event)) {
a6b691d8 1189 debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER);
4f48fb42 1190 $action->event = QUESTION_EVENTSAVE;
516cf3eb 1191 }
f30bbcaf 1192 // If submitted then compare against last graded
516cf3eb 1193 // responses, not last given responses in this case
4f48fb42 1194 if (question_isgradingevent($action->event)) {
516cf3eb 1195 $state->responses = $state->last_graded->responses;
1196 }
271ffe3f 1197
516cf3eb 1198 // Check for unchanged responses (exactly unchanged, not equivalent).
1199 // We also have to catch questions that the student has not yet attempted
47cdbd1f 1200 $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
4f2c86ad 1201 if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN &&
1202 question_isgradingevent($action->event)) {
47cdbd1f 1203 $sameresponses = false;
1204 }
516cf3eb 1205
f30bbcaf 1206 // If the response has not been changed then we do not have to process it again
1207 // unless the attempt is closing or validation is requested
4f48fb42 1208 if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
5a14d563 1209 and QUESTION_EVENTVALIDATE != $action->event) {
516cf3eb 1210 return true;
1211 }
1212
1213 // Roll back grading information to last graded state and set the new
1214 // responses
1215 $newstate = clone($state->last_graded);
1216 $newstate->responses = $action->responses;
1217 $newstate->seq_number = $state->seq_number + 1;
1218 $newstate->changed = true; // will assure that it gets saved to the database
ca56222d 1219 $newstate->last_graded = clone($state->last_graded);
516cf3eb 1220 $newstate->timestamp = $action->timestamp;
1221 $state = $newstate;
1222
1223 // Set the event to the action we will perform. The question type specific
4f48fb42 1224 // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
516cf3eb 1225 // attempt at the question causes the session to close
1226 $state->event = $action->event;
1227
4f48fb42 1228 if (!question_isgradingevent($action->event)) {
516cf3eb 1229 // Grade the response but don't update the overall grade
47e16978 1230 $QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions);
3bee1ead 1231
47e16978 1232 // Temporary hack because question types are not given enough control over what is going
1233 // on. Used by Opaque questions.
1234 // TODO fix this code properly.
1235 if (!empty($state->believeevent)) {
1236 // If the state was graded we need to ...
1237 if (question_state_is_graded($state)) {
1238 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1239
1240 // update the attempt grade
1241 $attempt->sumgrades -= (float)$state->last_graded->grade;
1242 $attempt->sumgrades += (float)$state->grade;
3bee1ead 1243
47e16978 1244 // and update the last_graded field.
1245 unset($state->last_graded);
1246 $state->last_graded = clone($state);
1247 unset($state->last_graded->changed);
1248 }
1249 } else {
1250 // Don't allow the processing to change the event type
1251 $state->event = $action->event;
1252 }
3bee1ead 1253
ca56222d 1254 } else { // grading event
516cf3eb 1255
271ffe3f 1256 // Unless the attempt is closing, we want to work out if the current responses
1257 // (or equivalent responses) were already given in the last graded attempt.
5a14d563 1258 if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
1259 $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
f30bbcaf 1260 $state->event = QUESTION_EVENTDUPLICATE;
516cf3eb 1261 }
f30bbcaf 1262
ca56222d 1263 // If we did not find a duplicate or if the attempt is closing, perform grading
5a14d563 1264 if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
1265 QUESTION_EVENTCLOSE == $action->event) {
f30bbcaf 1266
47e16978 1267 $QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions);
f30bbcaf 1268 // Calculate overall grade using correct penalty method
1269 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
516cf3eb 1270 }
516cf3eb 1271
47e16978 1272 // If the state was graded we need to ...
ca56222d 1273 if (question_state_is_graded($state)) {
47e16978 1274 // update the attempt grade
1275 $attempt->sumgrades -= (float)$state->last_graded->grade;
1276 $attempt->sumgrades += (float)$state->grade;
1277
1278 // and update the last_graded field.
ca56222d 1279 unset($state->last_graded);
1280 $state->last_graded = clone($state);
1281 unset($state->last_graded->changed);
1282 }
516cf3eb 1283 }
1284 $attempt->timemodified = $action->timestamp;
1285
1286 return true;
1287}
1288
1289/**
1290* Determine if event requires grading
1291*/
4f48fb42 1292function question_isgradingevent($event) {
f30bbcaf 1293 return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
516cf3eb 1294}
1295
516cf3eb 1296/**
1297* Applies the penalty from the previous graded responses to the raw grade
1298* for the current responses
1299*
1300* The grade for the question in the current state is computed by subtracting the
1301* penalty accumulated over the previous graded responses at the question from the
1302* raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
1303* the grade is set to zero. The ->grade field of the state object is modified to
1304* reflect the new grade but is never allowed to decrease.
1305* @param object $question The question for which the penalty is to be applied.
1306* @param object $state The state for which the grade is to be set from the
1307* raw grade and the cumulative penalty from the last
1308* graded state. The ->grade field is updated by applying
1309* the penalty scheme determined in $cmoptions to the ->raw_grade and
1310* ->last_graded->penalty fields.
1311* @param object $cmoptions The options set by the course module.
1312* The ->penaltyscheme field determines whether penalties
1313* for incorrect earlier responses are subtracted.
1314*/
4f48fb42 1315function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
b423cff1 1316 // TODO. Quiz dependancy. The fact that the attempt that is passed in here
1317 // is from quiz_attempts, and we use things like $cmoptions->timelimit.
3bee1ead 1318
f30bbcaf 1319 // deal with penalty
516cf3eb 1320 if ($cmoptions->penaltyscheme) {
47e16978 1321 $state->grade = $state->raw_grade - $state->sumpenalty;
1322 $state->sumpenalty += (float) $state->penalty;
516cf3eb 1323 } else {
1324 $state->grade = $state->raw_grade;
1325 }
1326
1327 // deal with timelimit
1328 if ($cmoptions->timelimit) {
1329 // We allow for 5% uncertainty in the following test
c7318911 1330 if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 63) {
1331 $cm = get_coursemodule_from_instance('quiz', $cmoptions->id);
1332 if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id),
1333 $attempt->userid, false)) {
1334 $state->grade = 0;
1335 }
516cf3eb 1336 }
1337 }
1338
1339 // deal with closing time
1340 if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
1341 and !$attempt->preview) { // ignore closing time for previews
1342 $state->grade = 0;
1343 }
1344
1345 // Ensure that the grade does not go down
1346 $state->grade = max($state->grade, $state->last_graded->grade);
1347}
1348
516cf3eb 1349/**
1350* Print the icon for the question type
1351*
1352* @param object $question The question object for which the icon is required
516cf3eb 1353* @param boolean $return If true the functions returns the link as a string
1354*/
dcc2ffde 1355function print_question_icon($question, $return = false) {
dc1f00de 1356 global $QTYPES, $CFG;
516cf3eb 1357
a95cd8a6 1358 $namestr = $QTYPES[$question->qtype]->menu_name();
dcc2ffde 1359 $html = '<img src="' . $CFG->wwwroot . '/question/type/' .
1360 $question->qtype . '/icon.gif" alt="' .
1361 $namestr . '" title="' . $namestr . '" />';
516cf3eb 1362 if ($return) {
1363 return $html;
1364 } else {
1365 echo $html;
1366 }
1367}
1368
516cf3eb 1369/**
1370* Returns a html link to the question image if there is one
1371*
1372* @return string The html image tag or the empy string if there is no image.
1373* @param object $question The question object
1374*/
9fc3100f 1375function get_question_image($question) {
516cf3eb 1376
1377 global $CFG;
1378 $img = '';
1379
9fc3100f 1380 if (!$category = get_record('question_categories', 'id', $question->category)){
5a2a5331 1381 print_error('invalid category id '.$question->category);
9fc3100f 1382 }
1383 $coursefilesdir = get_filesdir_from_context(get_context_instance_by_id($category->contextid));
1384
516cf3eb 1385 if ($question->image) {
1386
1387 if (substr(strtolower($question->image), 0, 7) == 'http://') {
1388 $img .= $question->image;
1389
1390 } else if ($CFG->slasharguments) { // Use this method if possible for better caching
9fc3100f 1391 $img .= "$CFG->wwwroot/file.php/$coursefilesdir/$question->image";
516cf3eb 1392
1393 } else {
9fc3100f 1394 $img .= "$CFG->wwwroot/file.php?file=/$coursefilesdir/$question->image";
516cf3eb 1395 }
1396 }
1397 return $img;
1398}
b6e907a2 1399
1400function question_print_comment_box($question, $state, $attempt, $url) {
2a2aba27 1401 global $CFG;
1402
848d886e 1403 $prefix = 'response';
59fa45d0 1404 $usehtmleditor = can_use_html_editor();
2a2aba27 1405 $grade = round($state->last_graded->grade, 3);
1406 echo '<form method="post" action="'.$url.'">';
1407 include($CFG->dirroot.'/question/comment.html');
1408 echo '<input type="hidden" name="attempt" value="'.$attempt->uniqueid.'" />';
1409 echo '<input type="hidden" name="question" value="'.$question->id.'" />';
1410 echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
1411 echo '<input type="submit" name="submit" value="'.get_string('save', 'quiz').'" />';
1412 echo '</form>';
1413
1414 if ($usehtmleditor) {
848d886e 1415 use_html_editor();
2a2aba27 1416 }
b6e907a2 1417}
1418
1419function question_process_comment($question, &$state, &$attempt, $comment, $grade) {
1420
1421 // Update the comment and save it in the database
fca490bc 1422 $comment = trim($comment);
3e3e5a40 1423 $state->manualcomment = $comment;
13835c77 1424 if (!set_field('question_sessions', 'manualcomment', $comment, 'attemptid', $attempt->uniqueid, 'questionid', $question->id)) {
5a2a5331 1425 print_error("Cannot save comment");
b6e907a2 1426 }
95ac8a40 1427
1428 // Update the attempt if the score has changed.
b6e907a2 1429 if (abs($state->last_graded->grade - $grade) > 0.002) {
b6e907a2 1430 $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
1431 $attempt->timemodified = time();
1432 if (!update_record('quiz_attempts', $attempt)) {
5a2a5331 1433 print_error('Failed to save the current quiz attempt!');
b6e907a2 1434 }
95ac8a40 1435 }
b6e907a2 1436
95ac8a40 1437 // Update the state if either the score has changed, or this is the first
fca490bc 1438 // manual grade event and there is actually a grade of comment to process.
95ac8a40 1439 // We don't need to store the modified state in the database, we just need
1440 // to set the $state->changed flag.
1441 if (abs($state->last_graded->grade - $grade) > 0.002 ||
fca490bc 1442 ($state->last_graded->event != QUESTION_EVENTMANUALGRADE && ($grade > 0.002 || $comment != ''))) {
84bf852c 1443
1444 // We want to update existing state (rather than creating new one) if it
1445 // was itself created by a manual grading event.
1446 $state->update = ($state->event == QUESTION_EVENTMANUALGRADE) ? 1 : 0;
1447
1448 // Update the other parts of the state object.
b6e907a2 1449 $state->raw_grade = $grade;
1450 $state->grade = $grade;
1451 $state->penalty = 0;
1452 $state->timestamp = time();
36be25f6 1453 $state->seq_number++;
95ac8a40 1454 $state->event = QUESTION_EVENTMANUALGRADE;
1455
b6e907a2 1456 // Update the last graded state (don't simplify!)
1457 unset($state->last_graded);
1458 $state->last_graded = clone($state);
95ac8a40 1459
1460 // We need to indicate that the state has changed in order for it to be saved.
1461 $state->changed = 1;
b6e907a2 1462 }
1463
1464}
1465
516cf3eb 1466/**
1467* Construct name prefixes for question form element names
1468*
1469* Construct the name prefix that should be used for example in the
1470* names of form elements created by questions.
4f48fb42 1471* This is called by {@link get_question_options()}
516cf3eb 1472* to set $question->name_prefix.
1473* This name prefix includes the question id which can be
4f48fb42 1474* extracted from it with {@link question_get_id_from_name_prefix()}.
516cf3eb 1475*
1476* @return string
1477* @param integer $id The question id
1478*/
4f48fb42 1479function question_make_name_prefix($id) {
516cf3eb 1480 return 'resp' . $id . '_';
1481}
1482
1483/**
1484* Extract question id from the prefix of form element names
1485*
1486* @return integer The question id
1487* @param string $name The name that contains a prefix that was
4f48fb42 1488* constructed with {@link question_make_name_prefix()}
516cf3eb 1489*/
4f48fb42 1490function question_get_id_from_name_prefix($name) {
516cf3eb 1491 if (!preg_match('/^resp([0-9]+)_/', $name, $matches))
1492 return false;
1493 return (integer) $matches[1];
1494}
1495
4f48fb42 1496/**
4dca7e51 1497 * Returns the unique id for a new attempt
1498 *
1499 * Every module can keep their own attempts table with their own sequential ids but
1500 * the question code needs to also have a unique id by which to identify all these
1501 * attempts. Hence a module, when creating a new attempt, calls this function and
1502 * stores the return value in the 'uniqueid' field of its attempts table.
4f48fb42 1503 */
36be25f6 1504function question_new_attempt_uniqueid($modulename='quiz') {
516cf3eb 1505 global $CFG;
d7444d44 1506 $attempt = new stdClass;
36be25f6 1507 $attempt->modulename = $modulename;
1508 if (!$id = insert_record('question_attempts', $attempt)) {
5a2a5331 1509 print_error('Could not create new entry in question_attempts table');
36be25f6 1510 }
1511 return $id;
516cf3eb 1512}
1513
36be25f6 1514/**
1515 * Creates a stamp that uniquely identifies this version of the question
cbe20043 1516 *
1517 * In future we want this to use a hash of the question data to guarantee that
1518 * identical versions have the same version stamp.
1519 *
1520 * @param object $question
1521 * @return string A unique version stamp
1522 */
1523function question_hash($question) {
1524 return make_unique_id_code();
1525}
1526
516cf3eb 1527
1528/// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
99ba746d 1529/**
1530 * Get the HTML that needs to be included in the head tag when the
1531 * questions in $questionlist are printed in the gives states.
1532 *
1533 * @param array $questionlist a list of questionids of the questions what will appear on this page.
1534 * @param array $questions an array of question objects, whose keys are question ids.
1535 * Must contain all the questions in $questionlist
1536 * @param array $states an array of question state objects, whose keys are question ids.
1537 * Must contain the state of all the questions in $questionlist
1538 *
1539 * @return string some HTML code that can go inside the head tag.
1540 */
1541function get_html_head_contributions(&$questionlist, &$questions, &$states) {
1542 global $QTYPES;
516cf3eb 1543
99ba746d 1544 $contributions = array();
1545 foreach ($questionlist as $questionid) {
1546 $question = $questions[$questionid];
1547 $contributions = array_merge($contributions,
1548 $QTYPES[$question->qtype]->get_html_head_contributions(
1549 $question, $states[$questionid]));
1550 }
1551 return implode("\n", array_unique($contributions));
1552}
1553
1554/**
4dca7e51 1555 * Prints a question
1556 *
1557 * Simply calls the question type specific print_question() method.
1558 * @param object $question The question to be rendered.
1559 * @param object $state The state to render the question in.
1560 * @param integer $number The number for this question.
1561 * @param object $cmoptions The options specified by the course module
1562 * @param object $options An object specifying the rendering options.
1563 */
4f48fb42 1564function print_question(&$question, &$state, $number, $cmoptions, $options=null) {
f02c6f01 1565 global $QTYPES;
516cf3eb 1566
f02c6f01 1567 $QTYPES[$question->qtype]->print_question($question, $state, $number,
516cf3eb 1568 $cmoptions, $options);
1569}
f5565b69 1570/**
99ba746d 1571 * Saves question options
1572 *
1573 * Simply calls the question type specific save_question_options() method.
1574 */
f5565b69 1575function save_question_options($question) {
1576 global $QTYPES;
1577
1578 $QTYPES[$question->qtype]->save_question_options($question);
1579}
516cf3eb 1580
1581/**
1582* Gets all teacher stored answers for a given question
1583*
1584* Simply calls the question type specific get_all_responses() method.
1585*/
1586// ULPGC ecastro
4f48fb42 1587function get_question_responses($question, $state) {
f02c6f01 1588 global $QTYPES;
1589 $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
516cf3eb 1590 return $r;
1591}
1592
1593
1594/**
dc1f00de 1595* Gets the response given by the user in a particular state
516cf3eb 1596*
1597* Simply calls the question type specific get_actual_response() method.
1598*/
1599// ULPGC ecastro
4f48fb42 1600function get_question_actual_response($question, $state) {
f02c6f01 1601 global $QTYPES;
516cf3eb 1602
f02c6f01 1603 $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
516cf3eb 1604 return $r;
1605}
1606
1607/**
dc1f00de 1608* TODO: document this
516cf3eb 1609*/
1610// ULPGc ecastro
4f48fb42 1611function get_question_fraction_grade($question, $state) {
f02c6f01 1612 global $QTYPES;
516cf3eb 1613
f02c6f01 1614 $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
516cf3eb 1615 return $r;
1616}
1617
1618
1619/// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
1620
6cb4910c 1621/**
1622 * returns the categories with their names ordered following parent-child relationships
1623 * finally it tries to return pending categories (those being orphaned, whose parent is
1624 * incorrect) to avoid missing any category from original array.
1625 */
1626function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
1627 $children = array();
1628 $keys = array_keys($categories);
1629
1630 foreach ($keys as $key) {
1631 if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
1632 $children[$key] = $categories[$key];
1633 $categories[$key]->processed = true;
1634 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1635 }
1636 }
1637 //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
1638 if ($level == 1) {
1639 foreach ($keys as $key) {
1640 //If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
1641 if (!isset($categories[$key]->processed) && !record_exists('question_categories', 'course', $categories[$key]->course, 'id', $categories[$key]->parent)) {
1642 $children[$key] = $categories[$key];
1643 $categories[$key]->processed = true;
1644 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1645 }
1646 }
1647 }
1648 return $children;
1649}
1650
1651/**
062a7522 1652 * Private method, only for the use of add_indented_names().
271ffe3f 1653 *
062a7522 1654 * Recursively adds an indentedname field to each category, starting with the category
271ffe3f 1655 * with id $id, and dealing with that category and all its children, and
062a7522 1656 * return a new array, with those categories in the right order.
1657 *
271ffe3f 1658 * @param array $categories an array of categories which has had childids
062a7522 1659 * fields added by flatten_category_tree(). Passed by reference for
1660 * performance only. It is not modfied.
1661 * @param int $id the category to start the indenting process from.
1662 * @param int $depth the indent depth. Used in recursive calls.
1663 * @return array a new array of categories, in the right order for the tree.
6cb4910c 1664 */
3bee1ead 1665function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
271ffe3f 1666
062a7522 1667 // Indent the name of this category.
1668 $newcategories = array();
1669 $newcategories[$id] = $categories[$id];
1670 $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
271ffe3f 1671
062a7522 1672 // Recursively indent the children.
1673 foreach ($categories[$id]->childids as $childid) {
3bee1ead 1674 if ($childid != $nochildrenof){
1675 $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1, $nochildrenof);
1676 }
6cb4910c 1677 }
271ffe3f 1678
062a7522 1679 // Remove the childids array that were temporarily added.
1680 unset($newcategories[$id]->childids);
271ffe3f 1681
062a7522 1682 return $newcategories;
6cb4910c 1683}
1684
1685/**
062a7522 1686 * Format categories into an indented list reflecting the tree structure.
271ffe3f 1687 *
062a7522 1688 * @param array $categories An array of category objects, for example from the.
1689 * @return array The formatted list of categories.
6cb4910c 1690 */
3bee1ead 1691function add_indented_names($categories, $nochildrenof = -1) {
6cb4910c 1692
271ffe3f 1693 // Add an array to each category to hold the child category ids. This array will be removed
062a7522 1694 // again by flatten_category_tree(). It should not be used outside these two functions.
1695 foreach (array_keys($categories) as $id) {
1696 $categories[$id]->childids = array();
6cb4910c 1697 }
1698
062a7522 1699 // Build the tree structure, and record which categories are top-level.
271ffe3f 1700 // We have to be careful, because the categories array may include published
062a7522 1701 // categories from other courses, but not their parents.
1702 $toplevelcategoryids = array();
1703 foreach (array_keys($categories) as $id) {
1704 if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
1705 $categories[$categories[$id]->parent]->childids[] = $id;
1706 } else {
1707 $toplevelcategoryids[] = $id;
6cb4910c 1708 }
1709 }
1710
062a7522 1711 // Flatten the tree to and add the indents.
1712 $newcategories = array();
1713 foreach ($toplevelcategoryids as $id) {
3bee1ead 1714 $newcategories = $newcategories + flatten_category_tree($categories, $id, 0, $nochildrenof);
6cb4910c 1715 }
1716
062a7522 1717 return $newcategories;
6cb4910c 1718}
2dd6d66b 1719
516cf3eb 1720/**
062a7522 1721 * Output a select menu of question categories.
271ffe3f 1722 *
062a7522 1723 * Categories from this course and (optionally) published categories from other courses
271ffe3f 1724 * are included. Optionally, only categories the current user may edit can be included.
062a7522 1725 *
1726 * @param integer $courseid the id of the course to get the categories for.
1727 * @param integer $published if true, include publised categories from other courses.
1728 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
1729 * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
1730 */
3bee1ead 1731function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) {
1732 $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof);
3ed998f3 1733 if ($selected) {
1734 $nothing = '';
1735 } else {
1736 $nothing = 'choose';
516cf3eb 1737 }
3bee1ead 1738 choose_from_menu_nested($categoriesarray, 'category', $selected, $nothing);
516cf3eb 1739}
1740
8f0f605d 1741/**
1742* Gets the default category in the most specific context.
1743* If no categories exist yet then default ones are created in all contexts.
1744*
1745* @param array $contexts The context objects for this context and all parent contexts.
1746* @return object The default category - the category in the course context
1747*/
1748function question_make_default_categories($contexts) {
1749 $toreturn = null;
1750 // If it already exists, just return it.
1751 foreach ($contexts as $key => $context) {
1752 if (!$categoryrs = get_recordset_select("question_categories", "contextid = '{$context->id}'", 'sortorder, name', '*', '', 1)) {
5a2a5331 1753 print_error('error getting category record');
8f0f605d 1754 } else {
1755 if (!$category = rs_fetch_record($categoryrs)){
1756 // Otherwise, we need to make one
1757 $category = new stdClass;
1758 $contextname = print_context_name($context, false, true);
1759 $category->name = addslashes(get_string('defaultfor', 'question', $contextname));
1760 $category->info = addslashes(get_string('defaultinfofor', 'question', $contextname));
1761 $category->contextid = $context->id;
1762 $category->parent = 0;
1763 $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically.
1764 $category->stamp = make_unique_id_code();
1765 if (!$category->id = insert_record('question_categories', $category)) {
5a2a5331 1766 print_error('Error creating a default category for context '.print_context_name($context));
8f0f605d 1767 }
1768 }
1769 }
1770 if ($context->contextlevel == CONTEXT_COURSE){
1771 $toreturn = clone($category);
1772 }
1773 }
1774
1775
1776 return $toreturn;
1777}
1778
375ed78a 1779/**
3bee1ead 1780 * Get all the category objects, including a count of the number of questions in that category,
1781 * for all the categories in the lists $contexts.
375ed78a 1782 *
3bee1ead 1783 * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
1784 * @param string $sortorder used as the ORDER BY clause in the select statement.
1785 * @return array of category objects.
375ed78a 1786 */
3bee1ead 1787function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
375ed78a 1788 global $CFG;
3bee1ead 1789 return get_records_sql("
1790 SELECT *, (SELECT count(1) FROM {$CFG->prefix}question q
1791 WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') as questioncount
d0508099 1792 FROM {$CFG->prefix}question_categories c
3bee1ead 1793 WHERE c.contextid IN ($contexts)
1794 ORDER BY $sortorder");
1795}
3a3c454e 1796
3bee1ead 1797/**
1798 * Output an array of question categories.
1799 */
1800function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) {
1801 global $CFG;
1802 $pcontexts = array();
1803 foreach($contexts as $context){
1804 $pcontexts[] = $context->id;
61d9b9fc 1805 }
3bee1ead 1806 $contextslist = join($pcontexts, ', ');
1807
1808 $categories = get_categories_for_contexts($contextslist);
375ed78a 1809
3bee1ead 1810 $categories = question_add_context_in_key($categories);
1811
1812 if ($top){
1813 $categories = question_add_tops($categories, $pcontexts);
1814 }
1815 $categories = add_indented_names($categories, $nochildrenof);
3ed998f3 1816
3bee1ead 1817 //sort cats out into different contexts
375ed78a 1818 $categoriesarray = array();
3bee1ead 1819 foreach ($pcontexts as $pcontext){
1820 $contextstring = print_context_name(get_context_instance_by_id($pcontext), true, true);
1821 foreach ($categories as $category) {
1822 if ($category->contextid == $pcontext){
1823 $cid = $category->id;
1824 if ($currentcat!= $cid || $currentcat==0) {
1e05bf54 1825 $countstring = (!empty($category->questioncount))?" ($category->questioncount)":'';
3bee1ead 1826 $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
1827 }
1828 }
375ed78a 1829 }
1830 }
3bee1ead 1831 if ($popupform){
1832 $popupcats = array();
1833 foreach ($categoriesarray as $contextstring => $optgroup){
1834 $popupcats[] = '--'.$contextstring;
1835 $popupcats = array_merge($popupcats, $optgroup);
1836 $popupcats[] = '--';
1837 }
1838 return $popupcats;
1839 } else {
1840 return $categoriesarray;
1841 }
375ed78a 1842}
1843
3bee1ead 1844function question_add_context_in_key($categories){
1845 $newcatarray = array();
1846 foreach ($categories as $id => $category) {
1847 $category->parent = "$category->parent,$category->contextid";
1848 $category->id = "$category->id,$category->contextid";
1849 $newcatarray["$id,$category->contextid"] = $category;
516cf3eb 1850 }
3bee1ead 1851 return $newcatarray;
1852}
1853function question_add_tops($categories, $pcontexts){
1854 $topcats = array();
1855 foreach ($pcontexts as $context){
1856 $newcat = new object();
1857 $newcat->id = "0,$context";
1858 $newcat->name = get_string('top');
1859 $newcat->parent = -1;
1860 $newcat->contextid = $context;
1861 $topcats["0,$context"] = $newcat;
1862 }
1863 //put topcats in at beginning of array - they'll be sorted into different contexts later.
1864 return array_merge($topcats, $categories);
516cf3eb 1865}
1866
516cf3eb 1867/**
b20ea669 1868 * Returns a comma separated list of ids of the category and all subcategories
1869 */
dc1f00de 1870function question_categorylist($categoryid) {
516cf3eb 1871 // returns a comma separated list of ids of the category and all subcategories
1872 $categorylist = $categoryid;
dc1f00de 1873 if ($subcategories = get_records('question_categories', 'parent', $categoryid, 'sortorder ASC', 'id, id')) {
516cf3eb 1874 foreach ($subcategories as $subcategory) {
dc1f00de 1875 $categorylist .= ','. question_categorylist($subcategory->id);
516cf3eb 1876 }
1877 }
1878 return $categorylist;
1879}
1880
f5c15407 1881
3bee1ead 1882
516cf3eb 1883
947217d7 1884//===========================
1885// Import/Export Functions
1886//===========================
1887
ff4b6492 1888/**
1889 * Get list of available import or export formats
1890 * @param string $type 'import' if import list, otherwise export list assumed
271ffe3f 1891 * @return array sorted list of import/export formats available
ff4b6492 1892**/
1893function get_import_export_formats( $type ) {
1894
1895 global $CFG;
dc1f00de 1896 $fileformats = get_list_of_plugins("question/format");
ff4b6492 1897
1898 $fileformatname=array();
947217d7 1899 require_once( "{$CFG->dirroot}/question/format.php" );
ff4b6492 1900 foreach ($fileformats as $key => $fileformat) {
1901 $format_file = $CFG->dirroot . "/question/format/$fileformat/format.php";
1902 if (file_exists( $format_file ) ) {
1903 require_once( $format_file );
1904 }
1905 else {
1906 continue;
1907 }
70c01adb 1908 $classname = "qformat_$fileformat";
ff4b6492 1909 $format_class = new $classname();
1910 if ($type=='import') {
1911 $provided = $format_class->provide_import();
1912 }
1913 else {
1914 $provided = $format_class->provide_export();
1915 }
1916 if ($provided) {
1917 $formatname = get_string($fileformat, 'quiz');
1918 if ($formatname == "[[$fileformat]]") {
1919 $formatname = $fileformat; // Just use the raw folder name
1920 }
1921 $fileformatnames[$fileformat] = $formatname;
1922 }
1923 }
1924 natcasesort($fileformatnames);
1925
1926 return $fileformatnames;
1927}
feb60a07 1928
1929
1930/**
1931* Create default export filename
1932*
1933* @return string default export filename
1934* @param object $course
1935* @param object $category
1936*/
1937function default_export_filename($course,$category) {
1938 //Take off some characters in the filename !!
1939 $takeoff = array(" ", ":", "/", "\\", "|");
57f1b914 1940 $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz")));
feb60a07 1941 //If non-translated, use "export"
1942 if (substr($export_word,0,1) == "[") {
1943 $export_word= "export";
1944 }
1945
1946 //Calculate the date format string
1947 $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz"));
1948 //If non-translated, use "%Y%m%d-%H%M"
1949 if (substr($export_date_format,0,1) == "[") {
1950 $export_date_format = "%%Y%%m%%d-%%H%%M";
1951 }
1952
1953 //Calculate the shortname
1954 $export_shortname = clean_filename($course->shortname);
1955 if (empty($export_shortname) or $export_shortname == '_' ) {
1956 $export_shortname = $course->id;
1957 }
1958
1959 //Calculate the category name
1960 $export_categoryname = clean_filename($category->name);
1961
1962 //Calculate the final export filename
1963 //The export word
1964 $export_name = $export_word."-";
1965 //The shortname
57f1b914 1966 $export_name .= moodle_strtolower($export_shortname)."-";
feb60a07 1967 //The category name
57f1b914 1968 $export_name .= moodle_strtolower($export_categoryname)."-";
feb60a07 1969 //The date format
1970 $export_name .= userdate(time(),$export_date_format,99,false);
6c58e198 1971 //Extension is supplied by format later.
feb60a07 1972
1973 return $export_name;
1974}
3bee1ead 1975class context_to_string_translator{
1976 /**
1977 * @var array used to translate between contextids and strings for this context.
1978 */
1979 var $contexttostringarray = array();
1980
1981 function context_to_string_translator($contexts){
1982 $this->generate_context_to_string_array($contexts);
1983 }
1984
1985 function context_to_string($contextid){
1986 return $this->contexttostringarray[$contextid];
1987 }
1988
1989 function string_to_context($contextname){
1990 $contextid = array_search($contextname, $this->contexttostringarray);
1991 return $contextid;
1992 }
1993
1994 function generate_context_to_string_array($contexts){
1995 if (!$this->contexttostringarray){
1996 $catno = 1;
1997 foreach ($contexts as $context){
1998 switch ($context->contextlevel){
1999 case CONTEXT_MODULE :
2000 $contextstring = 'module';
2001 break;
2002 case CONTEXT_COURSE :
2003 $contextstring = 'course';
2004 break;
2005 case CONTEXT_COURSECAT :
2006 $contextstring = "cat$catno";
2007 $catno++;
2008 break;
2009 case CONTEXT_SYSTEM :
2010 $contextstring = 'system';
2011 break;
2012 }
2013 $this->contexttostringarray[$context->id] = $contextstring;
2014 }
2015 }
2016 }
2017
2018}
feb60a07 2019
3bee1ead 2020
2021/**
2022 * Check capability on category
2023 * @param mixed $question object or id
2024 * @param string $cap 'add', 'edit', 'view', 'use', 'move'
2025 * @param integer $cachecat useful to cache all question records in a category
2026 * @return boolean this user has the capability $cap for this question $question?
2027 */
2028function question_has_capability_on($question, $cap, $cachecat = -1){
2029 global $USER;
2030 // these are capabilities on existing questions capabilties are
2031 //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
2032 $question_questioncaps = array('edit', 'view', 'use', 'move');
3bee1ead 2033 static $questions = array();
2034 static $categories = array();
2035 static $cachedcat = array();
9f1017a0 2036 if ($cachecat != -1 && (array_search($cachecat, $cachedcat)===FALSE)){
3bee1ead 2037 $questions += get_records('question', 'category', $cachecat);
2038 $cachedcat[] = $cachecat;
2039 }
2040 if (!is_object($question)){
2041 if (!isset($questions[$question])){
2042 if (!$questions[$question] = get_record('question', 'id', $question)){
0f6475b3 2043 print_error('questiondoesnotexist', 'question');
3bee1ead 2044 }
2045 }
2046 $question = $questions[$question];
2047 }
2048 if (!isset($categories[$question->category])){
2049 if (!$categories[$question->category] = get_record('question_categories', 'id', $question->category)){
2050 print_error('invalidcategory', 'quiz');
2051 }
2052 }
2053 $category = $categories[$question->category];
2054
2055 if (array_search($cap, $question_questioncaps)!== FALSE){
2056 if (!has_capability('moodle/question:'.$cap.'all', get_context_instance_by_id($category->contextid))){
2057 if ($question->createdby == $USER->id){
2058 return has_capability('moodle/question:'.$cap.'mine', get_context_instance_by_id($category->contextid));
2059 } else {
2060 return false;
2061 }
2062 } else {
2063 return true;
2064 }
2065 } else {
2066 return has_capability('moodle/question:'.$cap, get_context_instance_by_id($category->contextid));
2067 }
2068
2069}
2070
2071/**
2072 * Require capability on question.
2073 */
2074function question_require_capability_on($question, $cap){
2075 if (!question_has_capability_on($question, $cap)){
2076 print_error('nopermissions', '', '', $cap);
2077 }
2078 return true;
2079}
2080
2081function question_file_links_base_url($courseid){
2082 global $CFG;
2083 $baseurl = preg_quote("$CFG->wwwroot/file.php", '!');
2084 $baseurl .= '('.preg_quote('?file=', '!').')?';//may or may not
2085 //be using slasharguments, accept either
2086 $baseurl .= "/$courseid/";//course directory
2087 return $baseurl;
2088}
2089
2090/*
2091 * Find all course / site files linked to in a piece of html.
2092 * @param string html the html to search
2093 * @param int course search for files for courseid course or set to siteid for
2094 * finding site files.
2095 * @return array files with keys being files.
2096 */
2097function question_find_file_links_from_html($html, $courseid){
2098 global $CFG;
2099 $baseurl = question_file_links_base_url($courseid);
2100 $searchfor = '!'.
2101 '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$baseurl.'([^"]*)"'.
2102 '|'.
2103 '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$baseurl.'([^\']*)\''.
2104 '!i';
2105 $matches = array();
2106 $no = preg_match_all($searchfor, $html, $matches);
2107 if ($no){
2108 $rawurls = array_filter(array_merge($matches[5], $matches[10]));//array_filter removes empty elements
2109 //remove any links that point somewhere they shouldn't
2110 foreach (array_keys($rawurls) as $rawurlkey){
2111 if (!$cleanedurl = question_url_check($rawurls[$rawurlkey])){
2112 unset($rawurls[$rawurlkey]);
2113 } else {
2114 $rawurls[$rawurlkey] = $cleanedurl;
2115 }
2116
2117 }
2118 $urls = array_flip($rawurls);// array_flip removes duplicate files
2119 // and when we merge arrays will continue to automatically remove duplicates
2120 } else {
2121 $urls = array();
2122 }
2123 return $urls;
2124}
2125/*
2126 * Check that url doesn't point anywhere it shouldn't
2127 *
2128 * @param $url string relative url within course files directory
2129 * @return mixed boolean false if not OK or cleaned URL as string if OK
2130 */
2131function question_url_check($url){
2132 global $CFG;
2133 if ((substr(strtolower($url), 0, strlen($CFG->moddata)) == strtolower($CFG->moddata)) ||
2134 (substr(strtolower($url), 0, 10) == 'backupdata')){
2135 return false;
2136 } else {
2137 return clean_param($url, PARAM_PATH);
2138 }
2139}
2140
2141/*
2142 * Find all course / site files linked to in a piece of html.
2143 * @param string html the html to search
2144 * @param int course search for files for courseid course or set to siteid for
2145 * finding site files.
2146 * @return array files with keys being files.
2147 */
2148function question_replace_file_links_in_html($html, $fromcourseid, $tocourseid, $url, $destination, &$changed){
2149 global $CFG;
2150 if ($CFG->slasharguments) { // Use this method if possible for better caching
2151 $tourl = "$CFG->wwwroot/file.php/$tocourseid/$destination";
2152
2153 } else {
2154 $tourl = "$CFG->wwwroot/file.php?file=/$tocourseid/$destination";
2155 }
2156 $fromurl = question_file_links_base_url($fromcourseid).preg_quote($url, '!');
2157 $searchfor = array('!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$fromurl.'(")!i',
2158 '!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$fromurl.'(\')!i');
2159 $newhtml = preg_replace($searchfor, '\\1'.$tourl.'\\5', $html);
2160 if ($newhtml != $html){
2161 $changed = true;
2162 }
2163 return $newhtml;
2164}
2165
2166function get_filesdir_from_context($context){
2167 switch ($context->contextlevel){
2168 case CONTEXT_COURSE :
2169 $courseid = $context->instanceid;
2170 break;
2171 case CONTEXT_MODULE :
2172 $courseid = get_field('course_modules', 'course', 'id', $context->instanceid);
2173 break;
2174 case CONTEXT_COURSECAT :
2175 case CONTEXT_SYSTEM :
2176 $courseid = SITEID;
2177 break;
2178 default :
5a2a5331 2179 print_error('Unsupported contextlevel in category record!');
3bee1ead 2180 }
2181 return $courseid;
2182}
f5c15407 2183?>