lib MDL-19236 Added boilerplates and copyrights
[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
720be6f2 36
37define('QUESTION_EVENTS_GRADED', QUESTION_EVENTGRADE.','.
38 QUESTION_EVENTCLOSEANDGRADE.','.
39 QUESTION_EVENTMANUALGRADE);
8b92c1e3 40
41
42define('QUESTION_EVENTS_CLOSED', QUESTION_EVENTCLOSE.','.
43 QUESTION_EVENTCLOSEANDGRADE.','.
44 QUESTION_EVENTMANUALGRADE);
45
46define('QUESTION_EVENTS_CLOSED_OR_GRADED', QUESTION_EVENTGRADE.','.
47 QUESTION_EVENTS_CLOSED);
b55797b8 48
e56a08dc 49/**#@-*/
50
51/**#@+
a2156789 52 * The core question types.
6b11a0e8 53 */
60407982 54define("SHORTANSWER", "shortanswer");
55define("TRUEFALSE", "truefalse");
56define("MULTICHOICE", "multichoice");
57define("RANDOM", "random");
58define("MATCH", "match");
59define("RANDOMSAMATCH", "randomsamatch");
60define("DESCRIPTION", "description");
61define("NUMERICAL", "numerical");
62define("MULTIANSWER", "multianswer");
63define("CALCULATED", "calculated");
60407982 64define("ESSAY", "essay");
e56a08dc 65/**#@-*/
66
6b11a0e8 67/**
68 * Constant determines the number of answer boxes supplied in the editing
69 * form for multiple choice and similar question types.
70 */
4f48fb42 71define("QUESTION_NUMANS", "10");
e56a08dc 72
271ffe3f 73/**
74 * Constant determines the number of answer boxes supplied in the editing
75 * form for multiple choice and similar question types to start with, with
76 * the option of adding QUESTION_NUMANS_ADD more answers.
77 */
78define("QUESTION_NUMANS_START", 3);
79
80/**
81 * Constant determines the number of answer boxes to add in the editing
82 * form for multiple choice and similar question types when the user presses
83 * 'add form fields button'.
84 */
85define("QUESTION_NUMANS_ADD", 3);
86
1b8a7434 87/**
88 * The options used when popping up a question preview window in Javascript.
89 */
7d87171b 90define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=700,height=540');
516cf3eb 91
6b11a0e8 92/**#@+
93 * Option flags for ->optionflags
94 * The options are read out via bitwise operation using these constants
95 */
96/**
97 * Whether the questions is to be run in adaptive mode. If this is not set then
98 * a question closes immediately after the first submission of responses. This
99 * is how question is Moodle always worked before version 1.5
100 */
101define('QUESTION_ADAPTIVE', 1);
62e76c67 102/**#@-*/
6b11a0e8 103
62e76c67 104/**#@+
105 * Options used in forms that move files.
3bee1ead 106 */
107define('QUESTION_FILENOTHINGSELECTED', 0);
108define('QUESTION_FILEDONOTHING', 1);
109define('QUESTION_FILECOPY', 2);
110define('QUESTION_FILEMOVE', 3);
111define('QUESTION_FILEMOVELINKSONLY', 4);
62e76c67 112/**#@-*/
3bee1ead 113
62e76c67 114/**#@+
115 * Options for whether flags are shown/editable when rendering questions.
116 */
117define('QUESTION_FLAGSHIDDEN', 0);
118define('QUESTION_FLAGSSHOWN', 1);
119define('QUESTION_FLAGSEDITABLE', 2);
6b11a0e8 120/**#@-*/
121
f24493ec 122/// GLOBAL VARAIBLES //////////////////
123global $QTYPES;
516cf3eb 124/**
f24493ec 125 * Array holding question type objects. Initialised via calls to
126 * question_register_questiontype as the question type classes are included.
6b11a0e8 127 */
a2156789 128$QTYPES = array();
a2156789 129
130/**
131 * Add a new question type to the various global arrays above.
271ffe3f 132 *
a2156789 133 * @param object $qtype An instance of the new question type class.
134 */
135function question_register_questiontype($qtype) {
f24493ec 136 global $QTYPES;
271ffe3f 137
a2156789 138 $name = $qtype->name();
139 $QTYPES[$name] = $qtype;
a2156789 140}
516cf3eb 141
643ec47d 142require_once("$CFG->dirroot/question/type/questiontype.php");
516cf3eb 143
a2156789 144// Load the questiontype.php file for each question type
271ffe3f 145// These files in turn call question_register_questiontype()
a2156789 146// with a new instance of each qtype class.
3843d274 147$qtypenames = get_list_of_plugins('question/type');
f02c6f01 148foreach($qtypenames as $qtypename) {
149 // Instanciates all plug-in question types
643ec47d 150 $qtypefilepath= "$CFG->dirroot/question/type/$qtypename/questiontype.php";
f02c6f01 151
152 // echo "Loading $qtypename<br/>"; // Uncomment for debugging
153 if (is_readable($qtypefilepath)) {
154 require_once($qtypefilepath);
516cf3eb 155 }
156}
157
e2ae84a2 158/**
159 * An array of question type names translated to the user's language, suitable for use when
160 * creating a drop-down menu of options.
161 *
162 * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
163 * The array returned will only hold the names of all the question types that the user should
164 * be able to create directly. Some internal question types like random questions are excluded.
b9bd6da4 165 *
e2ae84a2 166 * @return array an array of question type names translated to the user's language.
167 */
168function question_type_menu() {
169 global $QTYPES;
7f92f0ad 170 static $menuoptions = null;
171 if (is_null($menuoptions)) {
af52ecee 172 $config = get_config('question');
7f92f0ad 173 $menuoptions = array();
e2ae84a2 174 foreach ($QTYPES as $name => $qtype) {
af52ecee 175 // Get the name if this qtype is enabled.
e2ae84a2 176 $menuname = $qtype->menu_name();
af52ecee 177 $enabledvar = $name . '_disabled';
178 if ($menuname && !isset($config->$enabledvar)) {
7f92f0ad 179 $menuoptions[$name] = $menuname;
e2ae84a2 180 }
181 }
af52ecee 182
183 $menuoptions = question_sort_qtype_array($menuoptions, $config);
e2ae84a2 184 }
7f92f0ad 185 return $menuoptions;
e2ae84a2 186}
187
af52ecee 188/**
189 * Sort an array of question type names according to the question type sort order stored in
190 * config_plugins. Entries for which there is no xxx_sortorder defined will go
191 * at the end, sorted according to asort($inarray, SORT_LOCALE_STRING).
192 * @param $inarray an array $qtype => $QTYPES[$qtype]->local_name().
193 * @param $config get_config('question'), if you happen to have it around, to save one DB query.
194 * @return array the sorted version of $inarray.
195 */
196function question_sort_qtype_array($inarray, $config = null) {
197 if (is_null($config)) {
198 $config = get_config('question');
199 }
200
201 $sortorder = array();
202 foreach ($inarray as $name => $notused) {
203 $sortvar = $name . '_sortorder';
204 if (isset($config->$sortvar)) {
205 $sortorder[$config->$sortvar] = $name;
206 }
207 }
208
209 ksort($sortorder);
210 $outarray = array();
211 foreach ($sortorder as $name) {
212 $outarray[$name] = $inarray[$name];
213 unset($inarray[$name]);
214 }
215 asort($inarray, SORT_LOCALE_STRING);
216 return array_merge($outarray, $inarray);
217}
218
219/**
220 * Move one question type in a list of question types. If you try to move one element
221 * off of the end, nothing will change.
222 *
223 * @param array $sortedqtypes An array $qtype => anything.
224 * @param string $tomove one of the keys from $sortedqtypes
225 * @param integer $direction +1 or -1
226 * @return array an array $index => $qtype, with $index from 0 to n in order, and
227 * the $qtypes in the same order as $sortedqtypes, except that $tomove will
228 * have been moved one place.
229 */
230function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
231 $neworder = array_keys($sortedqtypes);
232 // Find the element to move.
233 $key = array_search($tomove, $neworder);
234 if ($key === false) {
235 return $neworder;
236 }
237 // Work out the other index.
238 $otherkey = $key + $direction;
239 if (!isset($neworder[$otherkey])) {
240 return $neworder;
241 }
242 // Do the swap.
243 $swap = $neworder[$otherkey];
244 $neworder[$otherkey] = $neworder[$key];
245 $neworder[$key] = $swap;
246 return $neworder;
247}
248
249/**
250 * Save a new question type order to the config_plugins table.
251 * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
252 * @param $config get_config('question'), if you happen to have it around, to save one DB query.
253 */
254function question_save_qtype_order($neworder, $config = null) {
255 global $DB;
256
257 if (is_null($config)) {
258 $config = get_config('question');
259 }
260
261 foreach ($neworder as $index => $qtype) {
262 $sortvar = $qtype . '_sortorder';
263 if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
264 set_config($sortvar, $index + 1, 'question');
265 }
266 }
267}
268
516cf3eb 269/// OTHER CLASSES /////////////////////////////////////////////////////////
270
271/**
6b11a0e8 272 * This holds the options that are set by the course module
273 */
516cf3eb 274class cmoptions {
275 /**
276 * Whether a new attempt should be based on the previous one. If true
277 * then a new attempt will start in a state where all responses are set
278 * to the last responses from the previous attempt.
279 */
280 var $attemptonlast = false;
281
282 /**
283 * Various option flags. The flags are accessed via bitwise operations
284 * using the constants defined in the CONSTANTS section above.
285 */
6b11a0e8 286 var $optionflags = QUESTION_ADAPTIVE;
516cf3eb 287
288 /**
289 * Determines whether in the calculation of the score for a question
290 * penalties for earlier wrong responses within the same attempt will
291 * be subtracted.
292 */
293 var $penaltyscheme = true;
294
295 /**
296 * The maximum time the user is allowed to answer the questions withing
297 * an attempt. This is measured in minutes so needs to be multiplied by
298 * 60 before compared to timestamps. If set to 0 no timelimit will be applied
299 */
300 var $timelimit = 0;
301
302 /**
303 * Timestamp for the closing time. Responses submitted after this time will
304 * be saved but no credit will be given for them.
305 */
306 var $timeclose = 9999999999;
307
308 /**
309 * The id of the course from withing which the question is currently being used
310 */
311 var $course = SITEID;
312
313 /**
314 * Whether the answers in a multiple choice question should be randomly
315 * shuffled when a new attempt is started.
316 */
c4c11af8 317 var $shuffleanswers = true;
516cf3eb 318
319 /**
320 * The number of decimals to be shown when scores are printed
321 */
322 var $decimalpoints = 2;
516cf3eb 323}
324
325
516cf3eb 326/// FUNCTIONS //////////////////////////////////////////////////////
327
90c3f310 328/**
f67172b6 329 * Returns an array of names of activity modules that use this question
90c3f310 330 *
f67172b6 331 * @param object $questionid
332 * @return array of strings
90c3f310 333 */
f67172b6 334function question_list_instances($questionid) {
eb84a826 335 global $CFG, $DB;
90c3f310 336 $instances = array();
eb84a826 337 $modules = $DB->get_records('modules');
90c3f310 338 foreach ($modules as $module) {
7453df70 339 $fullmod = $CFG->dirroot . '/mod/' . $module->name;
340 if (file_exists($fullmod . '/lib.php')) {
341 include_once($fullmod . '/lib.php');
342 $fn = $module->name.'_question_list_instances';
343 if (function_exists($fn)) {
344 $instances = $instances + $fn($questionid);
345 }
90c3f310 346 }
347 }
348 return $instances;
349}
516cf3eb 350
e2b347e9 351/**
352 * Determine whether there arey any questions belonging to this context, that is whether any of its
353 * question categories contain any questions. This will return true even if all the questions are
354 * hidden.
355 *
356 * @param mixed $context either a context object, or a context id.
357 * @return boolean whether any of the question categories beloning to this context have
358 * any questions in them.
359 */
360function question_context_has_any_questions($context) {
eb84a826 361 global $DB;
e2b347e9 362 if (is_object($context)) {
363 $contextid = $context->id;
364 } else if (is_numeric($context)) {
365 $contextid = $context;
366 } else {
367 print_error('invalidcontextinhasanyquestions', 'question');
368 }
eb84a826 369 return $DB->record_exists_sql("SELECT *
370 FROM {question} q
371 JOIN {question_categories} qc ON qc.id = q.category
372 WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
e2b347e9 373}
374
271ffe3f 375/**
e9de4366 376 * Returns list of 'allowed' grades for grade selection
377 * formatted suitably for dropdown box function
378 * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
379 */
380function get_grade_options() {
30f09308 381 // define basic array of grades. This list comprises all fractions of the form:
382 // a. p/q for q <= 6, 0 <= p <= q
383 // b. p/10 for 0 <= p <= 10
384 // c. 1/q for 1 <= q <= 10
385 // d. 1/20
e9de4366 386 $grades = array(
30f09308 387 1.0000000,
388 0.9000000,
389 0.8333333,
390 0.8000000,
391 0.7500000,
392 0.7000000,
393 0.6666667,
394 0.6000000,
395 0.5000000,
396 0.4000000,
397 0.3333333,
398 0.3000000,
399 0.2500000,
400 0.2000000,
401 0.1666667,
402 0.1428571,
403 0.1250000,
404 0.1111111,
405 0.1000000,
406 0.0500000,
407 0.0000000);
e9de4366 408
409 // iterate through grades generating full range of options
410 $gradeoptionsfull = array();
411 $gradeoptions = array();
412 foreach ($grades as $grade) {
413 $percentage = 100 * $grade;
414 $neggrade = -$grade;
415 $gradeoptions["$grade"] = "$percentage %";
416 $gradeoptionsfull["$grade"] = "$percentage %";
417 $gradeoptionsfull["$neggrade"] = -$percentage." %";
418 }
419 $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
420
421 // sort lists
422 arsort($gradeoptions, SORT_NUMERIC);
423 arsort($gradeoptionsfull, SORT_NUMERIC);
424
425 // construct return object
426 $grades = new stdClass;
427 $grades->gradeoptions = $gradeoptions;
428 $grades->gradeoptionsfull = $gradeoptionsfull;
429
430 return $grades;
431}
432
8511669c 433/**
434 * match grade options
435 * if no match return error or match nearest
436 * @param array $gradeoptionsfull list of valid options
437 * @param int $grade grade to be tested
438 * @param string $matchgrades 'error' or 'nearest'
439 * @return mixed either 'fixed' value or false if erro
440 */
441function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
442 // if we just need an error...
443 if ($matchgrades=='error') {
444 foreach($gradeoptionsfull as $value => $option) {
2784b982 445 // slightly fuzzy test, never check floats for equality :-)
446 if (abs($grade-$value)<0.00001) {
8511669c 447 return $grade;
448 }
449 }
450 // didn't find a match so that's an error
451 return false;
452 }
453 // work out nearest value
454 else if ($matchgrades=='nearest') {
455 $hownear = array();
456 foreach($gradeoptionsfull as $value => $option) {
457 if ($grade==$value) {
458 return $grade;
459 }
460 $hownear[ $value ] = abs( $grade - $value );
461 }
462 // reverse sort list of deltas and grab the last (smallest)
463 asort( $hownear, SORT_NUMERIC );
464 reset( $hownear );
465 return key( $hownear );
466 }
467 else {
468 return false;
271ffe3f 469 }
8511669c 470}
471
f67172b6 472/**
473 * Tests whether a category is in use by any activity module
474 *
475 * @return boolean
271ffe3f 476 * @param integer $categoryid
f67172b6 477 * @param boolean $recursive Whether to examine category children recursively
478 */
479function question_category_isused($categoryid, $recursive = false) {
eb84a826 480 global $DB;
f67172b6 481
482 //Look at each question in the category
d11de005 483 if ($questions = $DB->get_records('question', array('category'=>$categoryid), '', 'id,qtype')) {
f67172b6 484 foreach ($questions as $question) {
485 if (count(question_list_instances($question->id))) {
486 return true;
487 }
488 }
489 }
490
491 //Look under child categories recursively
492 if ($recursive) {
eb84a826 493 if ($children = $DB->get_records('question_categories', array('parent'=>$categoryid))) {
f67172b6 494 foreach ($children as $child) {
495 if (question_category_isused($child->id, $recursive)) {
496 return true;
497 }
498 }
499 }
500 }
501
502 return false;
503}
504
0429cd86 505/**
506 * Deletes all data associated to an attempt from the database
507 *
93eb0ea3 508 * @param integer $attemptid The id of the attempt being deleted
0429cd86 509 */
510function delete_attempt($attemptid) {
eb84a826 511 global $QTYPES, $DB;
0429cd86 512
eb84a826 513 $states = $DB->get_records('question_states', array('attempt'=>$attemptid));
9523cbfc 514 if ($states) {
515 $stateslist = implode(',', array_keys($states));
3bee1ead 516
9523cbfc 517 // delete question-type specific data
518 foreach ($QTYPES as $qtype) {
519 $qtype->delete_states($stateslist);
520 }
0429cd86 521 }
522
523 // delete entries from all other question tables
524 // It is important that this is done only after calling the questiontype functions
eb84a826 525 $DB->delete_records("question_states", array("attempt"=>$attemptid));
526 $DB->delete_records("question_sessions", array("attemptid"=>$attemptid));
527 $DB->delete_records("question_attempts", array("id"=>$attemptid));
0429cd86 528}
f67172b6 529
516cf3eb 530/**
6b11a0e8 531 * Deletes question and all associated data from the database
532 *
90c3f310 533 * It will not delete a question if it is used by an activity module
6b11a0e8 534 * @param object $question The question being deleted
535 */
90c3f310 536function delete_question($questionid) {
eb84a826 537 global $QTYPES, $DB;
271ffe3f 538
3c573960 539 if (!$question = $DB->get_record('question', array('id'=>$questionid))) {
540 // In some situations, for example if this was a child of a
541 // Cloze question that was previously deleted, the question may already
542 // have gone. In this case, just do nothing.
543 return;
544 }
545
90c3f310 546 // Do not delete a question if it is used by an activity module
f67172b6 547 if (count(question_list_instances($questionid))) {
90c3f310 548 return;
549 }
550
551 // delete questiontype-specific data
3bee1ead 552 question_require_capability_on($question, 'edit');
553 if ($question) {
cf60a28a 554 if (isset($QTYPES[$question->qtype])) {
555 $QTYPES[$question->qtype]->delete_question($questionid);
556 }
557 } else {
558 echo "Question with id $questionid does not exist.<br />";
516cf3eb 559 }
90c3f310 560
eb84a826 561 if ($states = $DB->get_records('question_states', array('question'=>$questionid))) {
5cb9076a 562 $stateslist = implode(',', array_keys($states));
271ffe3f 563
5cb9076a 564 // delete questiontype-specific data
565 foreach ($QTYPES as $qtype) {
566 $qtype->delete_states($stateslist);
567 }
0429cd86 568 }
569
90c3f310 570 // delete entries from all other question tables
571 // It is important that this is done only after calling the questiontype functions
eb84a826 572 $DB->delete_records("question_answers", array("question"=>$questionid));
573 $DB->delete_records("question_states", array("question"=>$questionid));
574 $DB->delete_records("question_sessions", array("questionid"=>$questionid));
90c3f310 575
576 // Now recursively delete all child questions
d11de005 577 if ($children = $DB->get_records('question', array('parent' => $questionid), '', 'id,qtype')) {
516cf3eb 578 foreach ($children as $child) {
1d723a16 579 if ($child->id != $questionid) {
580 delete_question($child->id);
581 }
516cf3eb 582 }
583 }
271ffe3f 584
90c3f310 585 // Finally delete the question record itself
eb84a826 586 $DB->delete_records('question', array('id'=>$questionid));
90c3f310 587
588 return;
516cf3eb 589}
590
f67172b6 591/**
3bee1ead 592 * All question categories and their questions are deleted for this course.
f67172b6 593 *
3bee1ead 594 * @param object $mod an object representing the activity
f67172b6 595 * @param boolean $feedback to specify if the process must output a summary of its work
596 * @return boolean
597 */
598function question_delete_course($course, $feedback=true) {
eb84a826 599 global $DB;
600
f67172b6 601 //To store feedback to be showed at the end of the process
602 $feedbackdata = array();
603
604 //Cache some strings
f67172b6 605 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
3bee1ead 606 $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
eb84a826 607 $categoriescourse = $DB->get_records('question_categories', array('contextid'=>$coursecontext->id), 'parent', 'id, parent, name');
f67172b6 608
3bee1ead 609 if ($categoriescourse) {
f67172b6 610
611 //Sort categories following their tree (parent-child) relationships
3bee1ead 612 //this will make the feedback more readable
613 $categoriescourse = sort_categories_by_tree($categoriescourse);
614
615 foreach ($categoriescourse as $category) {
616
617 //Delete it completely (questions and category itself)
618 //deleting questions
d11de005 619 if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
3bee1ead 620 foreach ($questions as $question) {
621 delete_question($question->id);
f67172b6 622 }
eb84a826 623 $DB->delete_records("question", array("category"=>$category->id));
3bee1ead 624 }
625 //delete the category
eb84a826 626 $DB->delete_records('question_categories', array('id'=>$category->id));
f67172b6 627
3bee1ead 628 //Fill feedback
629 $feedbackdata[] = array($category->name, $strcatdeleted);
630 }
631 //Inform about changes performed if feedback is enabled
632 if ($feedback) {
633 $table = new stdClass;
634 $table->head = array(get_string('category','quiz'), get_string('action'));
635 $table->data = $feedbackdata;
636 print_table($table);
637 }
638 }
639 return true;
640}
f67172b6 641
e2b347e9 642/**
643 * Category is about to be deleted,
644 * 1/ All question categories and their questions are deleted for this course category.
645 * 2/ All questions are moved to new category
646 *
647 * @param object $category course category object
648 * @param object $newcategory empty means everything deleted, otherwise id of category where content moved
649 * @param boolean $feedback to specify if the process must output a summary of its work
650 * @return boolean
651 */
652function question_delete_course_category($category, $newcategory, $feedback=true) {
534792cd 653 global $DB;
654
e2b347e9 655 $context = get_context_instance(CONTEXT_COURSECAT, $category->id);
656 if (empty($newcategory)) {
657 $feedbackdata = array(); // To store feedback to be showed at the end of the process
658 $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
659 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
660
661 // Loop over question categories.
eb84a826 662 if ($categories = $DB->get_records('question_categories', array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
e2b347e9 663 foreach ($categories as $category) {
b9bd6da4 664
e2b347e9 665 // Deal with any questions in the category.
d11de005 666 if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
e2b347e9 667
668 // Try to delete each question.
669 foreach ($questions as $question) {
670 delete_question($question->id);
671 }
672
673 // Check to see if there were any questions that were kept because they are
674 // still in use somehow, even though quizzes in courses in this category will
675 // already have been deteted. This could happen, for example, if questions are
676 // added to a course, and then that course is moved to another category (MDL-14802).
534792cd 677 $questionids = $DB->get_records_menu('question', array('category'=>$category->id), '', 'id,1');
e2b347e9 678 if (!empty($questionids)) {
679 if (!$rescueqcategory = question_save_from_deletion(implode(',', array_keys($questionids)),
680 get_parent_contextid($context), print_context_name($context), $rescueqcategory)) {
681 return false;
682 }
683 $feedbackdata[] = array($category->name, get_string('questionsmovedto', 'question', $rescueqcategory->name));
684 }
685 }
686
687 // Now delete the category.
eb84a826 688 if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
e2b347e9 689 return false;
690 }
691 $feedbackdata[] = array($category->name, $strcatdeleted);
692
693 } // End loop over categories.
694 }
695
696 // Output feedback if requested.
697 if ($feedback and $feedbackdata) {
698 $table = new stdClass;
699 $table->head = array(get_string('questioncategory','question'), get_string('action'));
700 $table->data = $feedbackdata;
701 print_table($table);
702 }
703
704 } else {
705 // Move question categories ot the new context.
706 if (!$newcontext = get_context_instance(CONTEXT_COURSECAT, $newcategory->id)) {
707 return false;
708 }
eb84a826 709 if (!$DB->set_field('question_categories', 'contextid', $newcontext->id, array('contextid'=>$context->id))) {
e2b347e9 710 return false;
711 }
712 if ($feedback) {
713 $a = new stdClass;
714 $a->oldplace = print_context_name($context);
715 $a->newplace = print_context_name($newcontext);
716 notify(get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
717 }
718 }
719
720 return true;
721}
722
723/**
724 * Enter description here...
725 *
726 * @param string $questionids list of questionids
727 * @param object $newcontext the context to create the saved category in.
b9bd6da4 728 * @param string $oldplace a textual description of the think being deleted, e.g. from get_context_name
e2b347e9 729 * @param object $newcategory
b9bd6da4 730 * @return mixed false on
e2b347e9 731 */
732function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) {
eb84a826 733 global $DB;
734
e2b347e9 735 // Make a category in the parent context to move the questions to.
736 if (is_null($newcategory)) {
737 $newcategory = new object();
738 $newcategory->parent = 0;
739 $newcategory->contextid = $newcontextid;
eb84a826 740 $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
741 $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
e2b347e9 742 $newcategory->sortorder = 999;
743 $newcategory->stamp = make_unique_id_code();
eb84a826 744 if (!$newcategory->id = $DB->insert_record('question_categories', $newcategory)) {
e2b347e9 745 return false;
746 }
747 }
748
749 // Move any remaining questions to the 'saved' category.
750 if (!question_move_questions_to_category($questionids, $newcategory->id)) {
751 return false;
752 }
753 return $newcategory;
754}
755
3bee1ead 756/**
757 * All question categories and their questions are deleted for this activity.
758 *
759 * @param object $cm the course module object representing the activity
760 * @param boolean $feedback to specify if the process must output a summary of its work
761 * @return boolean
762 */
763function question_delete_activity($cm, $feedback=true) {
eb84a826 764 global $DB;
765
3bee1ead 766 //To store feedback to be showed at the end of the process
767 $feedbackdata = array();
f67172b6 768
3bee1ead 769 //Cache some strings
770 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
771 $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
eb84a826 772 if ($categoriesmods = $DB->get_records('question_categories', array('contextid'=>$modcontext->id), 'parent', 'id, parent, name')){
3bee1ead 773 //Sort categories following their tree (parent-child) relationships
774 //this will make the feedback more readable
775 $categoriesmods = sort_categories_by_tree($categoriesmods);
f67172b6 776
3bee1ead 777 foreach ($categoriesmods as $category) {
f67172b6 778
3bee1ead 779 //Delete it completely (questions and category itself)
780 //deleting questions
d11de005 781 if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
3bee1ead 782 foreach ($questions as $question) {
783 delete_question($question->id);
784 }
eb84a826 785 $DB->delete_records("question", array("category"=>$category->id));
f67172b6 786 }
3bee1ead 787 //delete the category
eb84a826 788 $DB->delete_records('question_categories', array('id'=>$category->id));
3bee1ead 789
790 //Fill feedback
791 $feedbackdata[] = array($category->name, $strcatdeleted);
f67172b6 792 }
793 //Inform about changes performed if feedback is enabled
794 if ($feedback) {
d7444d44 795 $table = new stdClass;
f67172b6 796 $table->head = array(get_string('category','quiz'), get_string('action'));
797 $table->data = $feedbackdata;
798 print_table($table);
799 }
800 }
801 return true;
802}
7fb1b88d 803
804/**
805 * This function should be considered private to the question bank, it is called from
806 * question/editlib.php question/contextmoveq.php and a few similar places to to the work of
807 * acutally moving questions and associated data. However, callers of this function also have to
808 * do other work, which is why you should not call this method directly from outside the questionbank.
809 *
810 * @param string $questionids a comma-separated list of question ids.
811 * @param integer $newcategory the id of the category to move to.
812 */
813function question_move_questions_to_category($questionids, $newcategory) {
1c69b885 814 global $DB;
815
7fb1b88d 816 $result = true;
817
818 // Move the questions themselves.
1c69b885 819 $result = $result && $DB->set_field_select('question', 'category', $newcategory, "id IN ($questionids)");
7fb1b88d 820
821 // Move any subquestions belonging to them.
1c69b885 822 $result = $result && $DB->set_field_select('question', 'category', $newcategory, "parent IN ($questionids)");
7fb1b88d 823
824 // TODO Deal with datasets.
825
826 return $result;
827}
828
3bee1ead 829/**
830 * @param array $row tab objects
831 * @param question_edit_contexts $contexts object representing contexts available from this context
832 * @param string $querystring to append to urls
833 * */
834function questionbank_navigation_tabs(&$row, $contexts, $querystring) {
835 global $CFG, $QUESTION_EDITTABCAPS;
836 $tabs = array(
837 'questions' =>array("$CFG->wwwroot/question/edit.php?$querystring", get_string('questions', 'quiz'), get_string('editquestions', 'quiz')),
838 'categories' =>array("$CFG->wwwroot/question/category.php?$querystring", get_string('categories', 'quiz'), get_string('editqcats', 'quiz')),
839 'import' =>array("$CFG->wwwroot/question/import.php?$querystring", get_string('import', 'quiz'), get_string('importquestions', 'quiz')),
840 'export' =>array("$CFG->wwwroot/question/export.php?$querystring", get_string('export', 'quiz'), get_string('exportquestions', 'quiz')));
841 foreach ($tabs as $tabname => $tabparams){
842 if ($contexts->have_one_edit_tab_cap($tabname)) {
843 $row[] = new tabobject($tabname, $tabparams[0], $tabparams[1], $tabparams[2]);
844 }
d187f660 845 }
846}
847
5a2a5331 848/**
78e7a3dd 849 * Given a list of ids, load the basic information about a set of questions from the questions table.
850 * The $join and $extrafields arguments can be used together to pull in extra data.
851 * See, for example, the usage in mod/quiz/attemptlib.php, and
852 * read the code below to see how the SQL is assembled. Throws exceptions on error.
5a2a5331 853 *
78e7a3dd 854 * @param array $questionids array of question ids.
855 * @param string $extrafields extra SQL code to be added to the query.
856 * @param string $join extra SQL code to be added to the query.
857 * @param array $extraparams values for any placeholders in $join.
858 * You are strongly recommended to use named placeholder.
5a2a5331 859 *
78e7a3dd 860 * @return array partially complete question objects. You need to call get_question_options
861 * on them before they can be properly used.
5a2a5331 862 */
78e7a3dd 863function question_preload_questions($questionids, $extrafields = '', $join = '', $extraparams = array()) {
eb84a826 864 global $CFG, $DB;
4089d9ec 865 if (empty($questionids)) {
866 return array();
867 }
5a2a5331 868 if ($join) {
78e7a3dd 869 $join = ' JOIN '.$join;
5a2a5331 870 }
871 if ($extrafields) {
872 $extrafields = ', ' . $extrafields;
873 }
78e7a3dd 874 list($questionidcondition, $params) = $DB->get_in_or_equal(
875 $questionids, SQL_PARAMS_NAMED, 'qid0000');
eb84a826 876 $sql = 'SELECT q.*' . $extrafields . ' FROM {question} q' . $join .
78e7a3dd 877 ' WHERE q.id ' . $questionidcondition;
5a2a5331 878
879 // Load the questions
78e7a3dd 880 if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) {
5a2a5331 881 return 'Could not load questions.';
882 }
883
78e7a3dd 884 foreach ($questions as $question) {
885 $question->_partiallyloaded = true;
886 }
887
b55797b8 888 // Note, a possible optimisation here would be to not load the TEXT fields
889 // (that is, questiontext and generalfeedback) here, and instead load them in
890 // question_load_questions. That would add one DB query, but reduce the amount
891 // of data transferred most of the time. I am not going to do this optimisation
892 // until it is shown to be worthwhile.
893
78e7a3dd 894 return $questions;
895}
896
897/**
898 * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
899 * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
900 * read the code below to see how the SQL is assembled. Throws exceptions on error.
901 *
902 * @param array $questionids array of question ids.
903 * @param string $extrafields extra SQL code to be added to the query.
904 * @param string $join extra SQL code to be added to the query.
905 * @param array $extraparams values for any placeholders in $join.
906 * You are strongly recommended to use named placeholder.
907 *
908 * @return array question objects.
909 */
910function question_load_questions($questionids, $extrafields = '', $join = '') {
911 $questions = question_preload_questions($questionids, $extrafields, $join);
912
5a2a5331 913 // Load the question type specific information
914 if (!get_question_options($questions)) {
915 return 'Could not load the question options';
916 }
917
918 return $questions;
919}
920
516cf3eb 921/**
d7444d44 922 * Private function to factor common code out of get_question_options().
271ffe3f 923 *
d7444d44 924 * @param object $question the question to tidy.
c599a682 925 * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
271ffe3f 926 * @return boolean true if successful, else false.
d7444d44 927 */
c599a682 928function _tidy_question(&$question, $loadtags = false) {
929 global $CFG, $QTYPES;
d7444d44 930 if (!array_key_exists($question->qtype, $QTYPES)) {
931 $question->qtype = 'missingtype';
932 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
933 }
934 $question->name_prefix = question_make_name_prefix($question->id);
78e7a3dd 935 if ($success = $QTYPES[$question->qtype]->get_question_options($question)) {
936 if (isset($question->_partiallyloaded)) {
937 unset($question->_partiallyloaded);
938 }
939 }
c599a682 940 if ($loadtags && !empty($CFG->usetags)) {
941 require_once($CFG->dirroot . '/tag/lib.php');
942 $question->tags = tag_get_tags_array('question', $question->id);
943 }
78e7a3dd 944 return $success;
d7444d44 945}
516cf3eb 946
d7444d44 947/**
948 * Updates the question objects with question type specific
949 * information by calling {@link get_question_options()}
950 *
951 * Can be called either with an array of question objects or with a single
952 * question object.
271ffe3f 953 *
d7444d44 954 * @param mixed $questions Either an array of question objects to be updated
955 * or just a single question object
c599a682 956 * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
d7444d44 957 * @return bool Indicates success or failure.
958 */
c599a682 959function get_question_options(&$questions, $loadtags = false) {
516cf3eb 960 if (is_array($questions)) { // deal with an array of questions
d7444d44 961 foreach ($questions as $i => $notused) {
c599a682 962 if (!_tidy_question($questions[$i], $loadtags)) {
516cf3eb 963 return false;
d7444d44 964 }
516cf3eb 965 }
966 return true;
967 } else { // deal with single question
c599a682 968 return _tidy_question($questions, $loadtags);
516cf3eb 969 }
970}
971
972/**
81d833ad 973 * Load the basic state information for
974 *
975 * @param integer $attemptid the attempt id to load the states for.
976 * @return array an array of state data from the database, you will subsequently
977 * need to call question_load_states to get fully loaded states that can be
978 * used by the question types. The states here should be sufficient for
979 * basic tasks like rendering navigation.
980 */
981function question_preload_states($attemptid) {
982 global $DB;
766df8f7 983 // Note, changes here probably also need to be reflected in
984 // regrade_question_in_attempt and question_load_specific_state.
516cf3eb 985
81d833ad 986 // The questionid field must be listed first so that it is used as the
eb84a826 987 // array index in the array returned by $DB->get_records_sql
62e76c67 988 $statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment, n.flagged, n.id as questionsessionid';
81d833ad 989
516cf3eb 990 // Load the newest states for the questions
eb84a826 991 $sql = "SELECT $statefields
992 FROM {question_states} s, {question_sessions} n
81d833ad 993 WHERE s.id = n.newest AND n.attemptid = ?";
994 $states = $DB->get_records_sql($sql, array($attemptid));
995 if (!$states) {
996 return false;
997 }
516cf3eb 998
999 // Load the newest graded states for the questions
eb84a826 1000 $sql = "SELECT $statefields
1001 FROM {question_states} s, {question_sessions} n
81d833ad 1002 WHERE s.id = n.newgraded AND n.attemptid = ?";
1003 $gradedstates = $DB->get_records_sql($sql, array($attemptid));
1004
1005 // Hook the two together.
1006 foreach ($states as $questionid => $state) {
1007 $states[$questionid]->_partiallyloaded = true;
1008 if ($gradedstates[$questionid]) {
81d833ad 1009 $states[$questionid]->last_graded = $gradedstates[$questionid];
40d1feeb 1010 $states[$questionid]->last_graded->_partiallyloaded = true;
81d833ad 1011 }
1012 }
1013
1014 return $states;
1015}
1016
1017/**
1018 * Finish loading the question states that were extracted from the database with
1019 * question_preload_states, creating new states for any question where there
1020 * is not a state in the database.
1021 *
1022 * @param array $questions the questions to load state for.
40d1feeb 1023 * @param array $states the partially loaded states this array is updated.
81d833ad 1024 * @param object $cmoptions options from the module we are loading the states for. E.g. $quiz.
1025 * @param object $attempt The attempt for which the question sessions are
1026 * to be restored or created.
1027 * @param mixed either the id of a previous attempt, if this attmpt is
1028 * building on a previous one, or false for a clean attempt.
40d1feeb 1029 * @return true or false for success or failure.
81d833ad 1030 */
1031function question_load_states(&$questions, &$states, $cmoptions, $attempt, $lastattemptid = false) {
1032 global $QTYPES, $DB;
516cf3eb 1033
1034 // loop through all questions and set the last_graded states
81d833ad 1035 foreach (array_keys($questions) as $qid) {
1036 if (isset($states[$qid])) {
1037 restore_question_state($questions[$qid], $states[$qid]);
1038 if (isset($states[$qid]->_partiallyloaded)) {
1039 unset($states[$qid]->_partiallyloaded);
1040 }
40d1feeb 1041 if (isset($states[$qid]->last_graded)) {
1042 restore_question_state($questions[$qid], $states[$qid]->last_graded);
1043 if (isset($states[$qid]->last_graded->_partiallyloaded)) {
1044 unset($states[$qid]->last_graded->_partiallyloaded);
1045 }
1046 } else {
1047 $states[$qid]->last_graded = clone($states[$qid]);
516cf3eb 1048 }
1049 } else {
fb708c11 1050 if ($lastattemptid) {
47ceac47 1051 // If the new attempt is to be based on this previous attempt.
1052 // Find the responses from the previous attempt and save them to the new session
fb708c11 1053
1054 // Load the last graded state for the question
1055 $statefields = 'n.questionid as question, s.*, n.sumpenalty';
eb84a826 1056 $sql = "SELECT $statefields
1057 FROM {question_states} s, {question_sessions} n
1058 WHERE s.id = n.newgraded
1059 AND n.attemptid = ?
1060 AND n.questionid = ?";
81d833ad 1061 if (!$laststate = $DB->get_record_sql($sql, array($lastattemptid, $qid))) {
fb708c11 1062 // Only restore previous responses that have been graded
1063 continue;
1064 }
1065 // Restore the state so that the responses will be restored
81d833ad 1066 restore_question_state($questions[$qid], $laststate);
1067 $states[$qid] = clone($laststate);
1068 unset($states[$qid]->id);
fb708c11 1069 } else {
d402153a 1070 // create a new empty state
81d833ad 1071 $states[$qid] = new object;
1072 $states[$qid]->question = $qid;
1073 $states[$qid]->responses = array('' => '');
1074 $states[$qid]->raw_grade = 0;
fb708c11 1075 }
1076
1077 // now fill/overide initial values
81d833ad 1078 $states[$qid]->attempt = $attempt->uniqueid;
1079 $states[$qid]->seq_number = 0;
1080 $states[$qid]->timestamp = $attempt->timestart;
1081 $states[$qid]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
1082 $states[$qid]->grade = 0;
1083 $states[$qid]->penalty = 0;
1084 $states[$qid]->sumpenalty = 0;
1085 $states[$qid]->manualcomment = '';
1086 $states[$qid]->flagged = 0;
fb708c11 1087
d23e3e11 1088 // Prevent further changes to the session from incrementing the
1089 // sequence number
81d833ad 1090 $states[$qid]->changed = true;
d23e3e11 1091
fb708c11 1092 if ($lastattemptid) {
1093 // prepare the previous responses for new processing
1094 $action = new stdClass;
1095 $action->responses = $laststate->responses;
1096 $action->timestamp = $laststate->timestamp;
1097 $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631
1098
1099 // Process these responses ...
81d833ad 1100 question_process_responses($questions[$qid], $states[$qid], $action, $cmoptions, $attempt);
fb708c11 1101
1102 // Fix for Bug #5506: When each attempt is built on the last one,
3bee1ead 1103 // preserve the options from any previous attempt.
fb708c11 1104 if ( isset($laststate->options) ) {
81d833ad 1105 $states[$qid]->options = $laststate->options;
fb708c11 1106 }
1107 } else {
1108 // Create the empty question type specific information
81d833ad 1109 if (!$QTYPES[$questions[$qid]->qtype]->create_session_and_responses(
1110 $questions[$qid], $states[$qid], $cmoptions, $attempt)) {
fb708c11 1111 return false;
1112 }
516cf3eb 1113 }
81d833ad 1114 $states[$qid]->last_graded = clone($states[$qid]);
516cf3eb 1115 }
1116 }
40d1feeb 1117 return true;
516cf3eb 1118}
1119
81d833ad 1120/**
1121* Loads the most recent state of each question session from the database
1122* or create new one.
1123*
1124* For each question the most recent session state for the current attempt
1125* is loaded from the question_states table and the question type specific data and
1126* responses are added by calling {@link restore_question_state()} which in turn
1127* calls {@link restore_session_and_responses()} for each question.
1128* If no states exist for the question instance an empty state object is
1129* created representing the start of a session and empty question
1130* type specific information and responses are created by calling
1131* {@link create_session_and_responses()}.
1132*
1133* @return array An array of state objects representing the most recent
1134* states of the question sessions.
1135* @param array $questions The questions for which sessions are to be restored or
1136* created.
1137* @param object $cmoptions
1138* @param object $attempt The attempt for which the question sessions are
1139* to be restored or created.
1140* @param mixed either the id of a previous attempt, if this attmpt is
1141* building on a previous one, or false for a clean attempt.
1142*/
1143function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) {
81d833ad 1144 // Preload the states.
1145 $states = question_preload_states($attempt->uniqueid);
1146 if (!$states) {
1147 $states = array();
1148 }
1149
1150 // Then finish the job.
40d1feeb 1151 if (!question_load_states($questions, $states, $cmoptions, $attempt, $lastattemptid)) {
1152 return false;
1153 }
81d833ad 1154
1155 return $states;
1156}
1157
b55797b8 1158/**
1159 * Load a particular previous state of a question.
1160 *
1161 * @param array $question The question to load the state for.
1162 * @param object $cmoptions Options from the specifica activity module, e.g. $quiz.
1163 * @param object $attempt The attempt for which the question sessions are to be loaded.
1164 * @param integer $stateid The id of a specific state of this question.
1165 * @return object the requested state. False on error.
1166 */
1167function question_load_specific_state($question, $cmoptions, $attempt, $stateid) {
8b92c1e3 1168 global $DB;
b55797b8 1169 // Load specified states for the question.
766df8f7 1170 // sess.sumpenalty is probably wrong here shoul really be a sum of penalties from before the one we are asking for.
1171 $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.flagged, sess.id as questionsessionid
b55797b8 1172 FROM {question_states} st, {question_sessions} sess
1173 WHERE st.id = ?
1174 AND st.attempt = ?
1175 AND sess.attemptid = st.attempt
1176 AND st.question = ?
1177 AND sess.questionid = st.question';
1178 $state = $DB->get_record_sql($sql, array($stateid, $attempt->id, $question->id));
1179 if (!$state) {
1180 return false;
1181 }
1182 restore_question_state($question, $state);
1183
1184 // Load the most recent graded states for the questions before the specified one.
766df8f7 1185 $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.flagged, sess.id as questionsessionid
b55797b8 1186 FROM {question_states} st, {question_sessions} sess
1187 WHERE st.seq_number <= ?
1188 AND st.attempt = ?
1189 AND sess.attemptid = st.attempt
1190 AND st.question = ?
1191 AND sess.questionid = st.question
8b92c1e3 1192 AND st.event IN ('.QUESTION_EVENTS_GRADED.') '.
b55797b8 1193 'ORDER BY st.seq_number DESC';
8b92c1e3 1194 $gradedstates = $DB->get_records_sql($sql, array($state->seq_number, $attempt->id, $question->id), 0, 1);
b55797b8 1195 if (empty($gradedstates)) {
1196 $state->last_graded = clone($state);
1197 } else {
1198 $gradedstate = reset($gradedstates);
1199 restore_question_state($question, $gradedstate);
1200 $state->last_graded = $gradedstate;
1201 }
1202 return $state;
1203}
516cf3eb 1204
1205/**
1206* Creates the run-time fields for the states
1207*
1208* Extends the state objects for a question by calling
1209* {@link restore_session_and_responses()}
516cf3eb 1210* @param object $question The question for which the state is needed
865b7534 1211* @param object $state The state as loaded from the database
1212* @return boolean Represents success or failure
516cf3eb 1213*/
4f48fb42 1214function restore_question_state(&$question, &$state) {
f02c6f01 1215 global $QTYPES;
516cf3eb 1216
1217 // initialise response to the value in the answer field
294ce987 1218 $state->responses = array('' => $state->answer);
516cf3eb 1219 unset($state->answer);
294ce987 1220 $state->manualcomment = isset($state->manualcomment) ? $state->manualcomment : '';
516cf3eb 1221
1222 // Set the changed field to false; any code which changes the
1223 // question session must set this to true and must increment
4f48fb42 1224 // ->seq_number. The save_question_session
516cf3eb 1225 // function will save the new state object to the database if the field is
1226 // set to true.
1227 $state->changed = false;
1228
1229 // Load the question type specific data
f02c6f01 1230 return $QTYPES[$question->qtype]
865b7534 1231 ->restore_session_and_responses($question, $state);
516cf3eb 1232
1233}
1234
1235/**
1236* Saves the current state of the question session to the database
1237*
1238* The state object representing the current state of the session for the
4f48fb42 1239* question is saved to the question_states table with ->responses[''] saved
516cf3eb 1240* to the answer field of the database table. The information in the
1241* question_sessions table is updated.
1242* The question type specific data is then saved.
bc2feba3 1243* @return mixed The id of the saved or updated state or false
516cf3eb 1244* @param object $question The question for which session is to be saved.
1245* @param object $state The state information to be saved. In particular the
1246* most recent responses are in ->responses. The object
1247* is updated to hold the new ->id.
1248*/
66d07f81 1249function save_question_session($question, $state) {
eb84a826 1250 global $QTYPES, $DB;
5e9170b4 1251
516cf3eb 1252 // Check if the state has changed
1253 if (!$state->changed && isset($state->id)) {
5e9170b4 1254 if (isset($state->newflaggedstate) && $state->flagged != $state->newflaggedstate) {
1255 // If this fails, don't worry too much, it is not critical data.
1256 question_update_flag($state->questionsessionid, $state->newflaggedstate);
1257 }
bc2feba3 1258 return $state->id;
516cf3eb 1259 }
1260 // Set the legacy answer field
1261 $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
1262
1263 // Save the state
36be25f6 1264 if (!empty($state->update)) { // this forces the old state record to be overwritten
eb84a826 1265 $DB->update_record('question_states', $state);
516cf3eb 1266 } else {
eb84a826 1267 if (!$state->id = $DB->insert_record('question_states', $state)) {
516cf3eb 1268 unset($state->id);
1269 unset($state->answer);
1270 return false;
1271 }
b6e907a2 1272 }
516cf3eb 1273
b6e907a2 1274 // create or update the session
b9bd6da4 1275 if (!$session = $DB->get_record('question_sessions', array('attemptid' => $state->attempt, 'questionid' => $question->id))) {
5e9170b4 1276 $session = new stdClass;
2e9b6d15 1277 $session->attemptid = $state->attempt;
1278 $session->questionid = $question->id;
1279 $session->newest = $state->id;
1280 // The following may seem weird, but the newgraded field needs to be set
1281 // already even if there is no graded state yet.
1282 $session->newgraded = $state->id;
1283 $session->sumpenalty = $state->sumpenalty;
3e3e5a40 1284 $session->manualcomment = $state->manualcomment;
5e9170b4 1285 $session->flagged = !empty($state->newflaggedstate);
eb84a826 1286 if (!$DB->insert_record('question_sessions', $session)) {
5e9170b4 1287 return false;
516cf3eb 1288 }
b6e907a2 1289 } else {
2e9b6d15 1290 $session->newest = $state->id;
1291 if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
1292 // this state is graded or newly opened, so it goes into the lastgraded field as well
1293 $session->newgraded = $state->id;
1294 $session->sumpenalty = $state->sumpenalty;
3e3e5a40 1295 $session->manualcomment = $state->manualcomment;
ef822868 1296 } else {
eb84a826 1297 $session->manualcomment = $session->manualcomment;
516cf3eb 1298 }
5e9170b4 1299 $session->flagged = !empty($state->newflaggedstate);
1300 if (!$DB->update_record('question_sessions', $session)) {
1301 return false;
1302 }
516cf3eb 1303 }
1304
1305 unset($state->answer);
1306
1307 // Save the question type specific state information and responses
5e9170b4 1308 if (!$QTYPES[$question->qtype]->save_session_and_responses($question, $state)) {
516cf3eb 1309 return false;
1310 }
5e9170b4 1311
516cf3eb 1312 // Reset the changed flag
1313 $state->changed = false;
bc2feba3 1314 return $state->id;
516cf3eb 1315}
1316
1317/**
1318* Determines whether a state has been graded by looking at the event field
1319*
1320* @return boolean true if the state has been graded
1321* @param object $state
1322*/
4f48fb42 1323function question_state_is_graded($state) {
8b92c1e3 1324 static $question_events_graded = array();
1325 if (!$question_events_graded){
1326 $question_events_graded = explode(',', QUESTION_EVENTS_GRADED);
1327 }
1328 return (in_array($state->event, $question_events_graded));
f30bbcaf 1329}
1330
1331/**
1332* Determines whether a state has been closed by looking at the event field
1333*
1334* @return boolean true if the state has been closed
1335* @param object $state
1336*/
1337function question_state_is_closed($state) {
8b92c1e3 1338 static $question_events_closed = array();
1339 if (!$question_events_closed){
1340 $question_events_closed = explode(',', QUESTION_EVENTS_CLOSED);
1341 }
1342 return (in_array($state->event, $question_events_closed));
516cf3eb 1343}
1344
1345
1346/**
4dca7e51 1347 * Extracts responses from submitted form
1348 *
1349 * This can extract the responses given to one or several questions present on a page
1350 * It returns an array with one entry for each question, indexed by question id
1351 * Each entry is an object with the properties
1352 * ->event The event that has triggered the submission. This is determined by which button
1353 * the user has pressed.
1354 * ->responses An array holding the responses to an individual question, indexed by the
1355 * name of the corresponding form element.
1356 * ->timestamp A unix timestamp
1357 * @return array array of action objects, indexed by question ids.
1358 * @param array $questions an array containing at least all questions that are used on the form
1359 * @param array $formdata the data submitted by the form on the question page
1360 * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted
1361 */
1362function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
516cf3eb 1363
4dca7e51 1364 $time = time();
516cf3eb 1365 $actions = array();
4dca7e51 1366 foreach ($formdata as $key => $response) {
516cf3eb 1367 // Get the question id from the response name
4f48fb42 1368 if (false !== ($quid = question_get_id_from_name_prefix($key))) {
516cf3eb 1369 // check if this is a valid id
1370 if (!isset($questions[$quid])) {
d89ec019 1371 print_error('formquestionnotinids', 'question');
516cf3eb 1372 }
1373
1374 // Remove the name prefix from the name
1375 //decrypt trying
1376 $key = substr($key, strlen($questions[$quid]->name_prefix));
1377 if (false === $key) {
1378 $key = '';
1379 }
1380 // Check for question validate and mark buttons & set events
1381 if ($key === 'validate') {
4f48fb42 1382 $actions[$quid]->event = QUESTION_EVENTVALIDATE;
4dca7e51 1383 } else if ($key === 'submit') {
f30bbcaf 1384 $actions[$quid]->event = QUESTION_EVENTSUBMIT;
516cf3eb 1385 } else {
1386 $actions[$quid]->event = $defaultevent;
1387 }
516cf3eb 1388 // Update the state with the new response
1389 $actions[$quid]->responses[$key] = $response;
271ffe3f 1390
4dca7e51 1391 // Set the timestamp
1392 $actions[$quid]->timestamp = $time;
516cf3eb 1393 }
1394 }
3ebdddf7 1395 foreach ($actions as $quid => $notused) {
1396 ksort($actions[$quid]->responses);
1397 }
516cf3eb 1398 return $actions;
1399}
1400
1401
bc75cc52 1402/**
1403 * Returns the html for question feedback image.
1404 * @param float $fraction value representing the correctness of the user's
1405 * response to a question.
1406 * @param boolean $selected whether or not the answer is the one that the
1407 * user picked.
1408 * @return string
1409 */
1410function question_get_feedback_image($fraction, $selected=true) {
bc75cc52 1411 global $CFG;
b10c38a3 1412 static $icons = array('correct' => 'tick_green', 'partiallycorrect' => 'tick_amber',
1413 'incorrect' => 'cross_red');
bc75cc52 1414
b10c38a3 1415 if ($selected) {
1416 $size = 'big';
bc75cc52 1417 } else {
b10c38a3 1418 $size = 'small';
bc75cc52 1419 }
b10c38a3 1420 $class = question_get_feedback_class($fraction);
1421 return '<img src="' . $CFG->pixpath.'/i/' . $icons[$class] . '_' . $size . '.gif" '.
1422 'alt="' . get_string($class, 'quiz') . '" class="icon" />';
bc75cc52 1423}
1424
bc75cc52 1425/**
1426 * Returns the class name for question feedback.
1427 * @param float $fraction value representing the correctness of the user's
1428 * response to a question.
1429 * @return string
1430 */
1431function question_get_feedback_class($fraction) {
b10c38a3 1432 if ($fraction >= 1/1.01) {
1433 return 'correct';
1434 } else if ($fraction > 0.0) {
1435 return 'partiallycorrect';
bc75cc52 1436 } else {
b10c38a3 1437 return 'incorrect';
bc75cc52 1438 }
bc75cc52 1439}
1440
516cf3eb 1441
1442/**
1443* For a given question in an attempt we walk the complete history of states
1444* and recalculate the grades as we go along.
1445*
1446* This is used when a question is changed and old student
1447* responses need to be marked with the new version of a question.
1448*
4f48fb42 1449* TODO: Make sure this is not quiz-specific
1450*
0a5b58af 1451* @return boolean Indicates whether the grade has changed
516cf3eb 1452* @param object $question A question object
1453* @param object $attempt The attempt, in which the question needs to be regraded.
1454* @param object $cmoptions
1455* @param boolean $verbose Optional. Whether to print progress information or not.
98f38217 1456* @param boolean $dryrun Optional. Whether to make changes to grades records
1457* or record that changes need to be made for a later regrade.
516cf3eb 1458*/
98f38217 1459function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false, $dryrun=false) {
eb84a826 1460 global $DB;
516cf3eb 1461
1462 // load all states for this question in this attempt, ordered in sequence
eb84a826 1463 if ($states = $DB->get_records('question_states',
1464 array('attempt'=>$attempt->uniqueid, 'question'=>$question->id),
865b7534 1465 'seq_number ASC')) {
516cf3eb 1466 $states = array_values($states);
1467
1468 // Subtract the grade for the latest state from $attempt->sumgrades to get the
271ffe3f 1469 // sumgrades for the attempt without this question.
516cf3eb 1470 $attempt->sumgrades -= $states[count($states)-1]->grade;
1471
1472 // Initialise the replaystate
766df8f7 1473 $replaystate = question_load_specific_state($question, $cmoptions, $attempt, $states[0]->id);
1474 $replaystate->sumpenalty = 0;
1475 $replaystate->last_graded->sumpenalty = 0;
516cf3eb 1476
0a5b58af 1477 $changed = false;
516cf3eb 1478 for($j = 1; $j < count($states); $j++) {
4f48fb42 1479 restore_question_state($question, $states[$j]);
516cf3eb 1480 $action = new stdClass;
1481 $action->responses = $states[$j]->responses;
1482 $action->timestamp = $states[$j]->timestamp;
1483
f30bbcaf 1484 // Change event to submit so that it will be reprocessed
766df8f7 1485 if (in_array($states[$j]->event, array(QUESTION_EVENTCLOSE,
1486 QUESTION_EVENTGRADE, QUESTION_EVENTCLOSEANDGRADE))) {
f30bbcaf 1487 $action->event = QUESTION_EVENTSUBMIT;
516cf3eb 1488
1489 // By default take the event that was saved in the database
1490 } else {
1491 $action->event = $states[$j]->event;
1492 }
36be25f6 1493
5e60643e 1494 if ($action->event == QUESTION_EVENTMANUALGRADE) {
b9bd6da4 1495 // Ensure that the grade is in range - in the past this was not checked,
994c8c35 1496 // but now it is (MDL-14835) - so we need to ensure the data is valid before
1497 // proceeding.
1498 if ($states[$j]->grade < 0) {
1499 $states[$j]->grade = 0;
98f38217 1500 $changed = true;
994c8c35 1501 } else if ($states[$j]->grade > $question->maxgrade) {
1502 $states[$j]->grade = $question->maxgrade;
98f38217 1503 $changed = true;
1504
994c8c35 1505 }
98f38217 1506 if (!$dryrun){
1507 $error = question_process_comment($question, $replaystate, $attempt,
1508 $replaystate->manualcomment, $states[$j]->grade);
1509 if (is_string($error)) {
1510 notify($error);
1511 }
1512 } else {
1513 $replaystate->grade = $states[$j]->grade;
994c8c35 1514 }
36be25f6 1515 } else {
36be25f6 1516 // Reprocess (regrade) responses
865b7534 1517 if (!question_process_responses($question, $replaystate,
766df8f7 1518 $action, $cmoptions, $attempt) && $verbose) {
1519 $a = new stdClass;
1520 $a->qid = $question->id;
1521 $a->stateid = $states[$j]->id;
1522 notify(get_string('errorduringregrade', 'question', $a));
36be25f6 1523 }
98f38217 1524 // We need rounding here because grades in the DB get truncated
1525 // e.g. 0.33333 != 0.3333333, but we want them to be equal here
1526 if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
1527 or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
1528 or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
1529 $changed = true;
1530 }
766df8f7 1531 // If this was previously a closed state, and it has been knoced back to
1532 // graded, then fix up the state again.
1533 if ($replaystate->event == QUESTION_EVENTGRADE &&
1534 ($states[$j]->event == QUESTION_EVENTCLOSE ||
1535 $states[$j]->event == QUESTION_EVENTCLOSEANDGRADE)) {
1536 $replaystate->event = $states[$j]->event;
1537 }
516cf3eb 1538 }
516cf3eb 1539
1540 $replaystate->id = $states[$j]->id;
271ffe3f 1541 $replaystate->changed = true;
d23e3e11 1542 $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
98f38217 1543 if (!$dryrun){
1544 save_question_session($question, $replaystate);
1545 }
516cf3eb 1546 }
0a5b58af 1547 if ($changed) {
98f38217 1548 if (!$dryrun){
1549 // TODO, call a method in quiz to do this, where 'quiz' comes from
1550 // the question_attempts table.
1551 $DB->update_record('quiz_attempts', $attempt);
1552 }
1553 }
1554 if ($changed){
1555 $toinsert = new object();
1556 $toinsert->oldgrade = round((float)$states[count($states)-1]->grade, 5);
1557 $toinsert->newgrade = round((float)$replaystate->grade, 5);
1558 $toinsert->attemptid = $attempt->uniqueid;
1559 $toinsert->questionid = $question->id;
1560 //the grade saved is the old grade if the new grade is saved
1561 //it is the new grade if this is a dry run.
1562 $toinsert->regraded = $dryrun?0:1;
1563 $toinsert->timemodified = time();
1564 $DB->insert_record('quiz_question_regrade', $toinsert);
1565 return true;
1566 } else {
1567 return false;
516cf3eb 1568 }
516cf3eb 1569 }
0a5b58af 1570 return false;
516cf3eb 1571}
1572
1573/**
1574* Processes an array of student responses, grading and saving them as appropriate
1575*
516cf3eb 1576* @param object $question Full question object, passed by reference
1577* @param object $state Full state object, passed by reference
1578* @param object $action object with the fields ->responses which
1579* is an array holding the student responses,
4f48fb42 1580* ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
516cf3eb 1581* and ->timestamp which is a timestamp from when the responses
1582* were submitted by the student.
1583* @param object $cmoptions
1584* @param object $attempt The attempt is passed by reference so that
1585* during grading its ->sumgrades field can be updated
373f0afd 1586* @return boolean Indicates success/failure
516cf3eb 1587*/
66d07f81 1588function question_process_responses($question, &$state, $action, $cmoptions, &$attempt) {
f02c6f01 1589 global $QTYPES;
516cf3eb 1590
1591 // if no responses are set initialise to empty response
1592 if (!isset($action->responses)) {
1593 $action->responses = array('' => '');
1594 }
1595
5e9170b4 1596 $state->newflaggedstate = !empty($action->responses['_flagged']);
1597
516cf3eb 1598 // make sure these are gone!
5e9170b4 1599 unset($action->responses['submit'], $action->responses['validate'], $action->responses['_flagged']);
516cf3eb 1600
1601 // Check the question session is still open
f30bbcaf 1602 if (question_state_is_closed($state)) {
516cf3eb 1603 return true;
1604 }
f30bbcaf 1605
516cf3eb 1606 // If $action->event is not set that implies saving
1607 if (! isset($action->event)) {
a6b691d8 1608 debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER);
4f48fb42 1609 $action->event = QUESTION_EVENTSAVE;
516cf3eb 1610 }
f30bbcaf 1611 // If submitted then compare against last graded
516cf3eb 1612 // responses, not last given responses in this case
4f48fb42 1613 if (question_isgradingevent($action->event)) {
516cf3eb 1614 $state->responses = $state->last_graded->responses;
1615 }
271ffe3f 1616
516cf3eb 1617 // Check for unchanged responses (exactly unchanged, not equivalent).
1618 // We also have to catch questions that the student has not yet attempted
47cdbd1f 1619 $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
4f2c86ad 1620 if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN &&
1621 question_isgradingevent($action->event)) {
47cdbd1f 1622 $sameresponses = false;
1623 }
516cf3eb 1624
f30bbcaf 1625 // If the response has not been changed then we do not have to process it again
1626 // unless the attempt is closing or validation is requested
4f48fb42 1627 if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
5a14d563 1628 and QUESTION_EVENTVALIDATE != $action->event) {
516cf3eb 1629 return true;
1630 }
1631
1632 // Roll back grading information to last graded state and set the new
1633 // responses
1634 $newstate = clone($state->last_graded);
1635 $newstate->responses = $action->responses;
1636 $newstate->seq_number = $state->seq_number + 1;
1637 $newstate->changed = true; // will assure that it gets saved to the database
ca56222d 1638 $newstate->last_graded = clone($state->last_graded);
516cf3eb 1639 $newstate->timestamp = $action->timestamp;
5e9170b4 1640 $newstate->newflaggedstate = $state->newflaggedstate;
1641 $newstate->flagged = $state->flagged;
1642 $newstate->questionsessionid = $state->questionsessionid;
516cf3eb 1643 $state = $newstate;
1644
1645 // Set the event to the action we will perform. The question type specific
4f48fb42 1646 // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
516cf3eb 1647 // attempt at the question causes the session to close
1648 $state->event = $action->event;
1649
4f48fb42 1650 if (!question_isgradingevent($action->event)) {
516cf3eb 1651 // Grade the response but don't update the overall grade
373f0afd 1652 if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
1653 return false;
1654 }
3bee1ead 1655
47e16978 1656 // Temporary hack because question types are not given enough control over what is going
1657 // on. Used by Opaque questions.
1658 // TODO fix this code properly.
1659 if (!empty($state->believeevent)) {
1660 // If the state was graded we need to ...
1661 if (question_state_is_graded($state)) {
1662 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1663
1664 // update the attempt grade
1665 $attempt->sumgrades -= (float)$state->last_graded->grade;
1666 $attempt->sumgrades += (float)$state->grade;
3bee1ead 1667
47e16978 1668 // and update the last_graded field.
1669 unset($state->last_graded);
1670 $state->last_graded = clone($state);
1671 unset($state->last_graded->changed);
1672 }
1673 } else {
1674 // Don't allow the processing to change the event type
1675 $state->event = $action->event;
1676 }
3bee1ead 1677
ca56222d 1678 } else { // grading event
516cf3eb 1679
271ffe3f 1680 // Unless the attempt is closing, we want to work out if the current responses
1681 // (or equivalent responses) were already given in the last graded attempt.
5a14d563 1682 if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
1683 $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
f30bbcaf 1684 $state->event = QUESTION_EVENTDUPLICATE;
516cf3eb 1685 }
f30bbcaf 1686
ca56222d 1687 // If we did not find a duplicate or if the attempt is closing, perform grading
5a14d563 1688 if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
1689 QUESTION_EVENTCLOSE == $action->event) {
373f0afd 1690 if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
1691 return false;
1692 }
f30bbcaf 1693
f30bbcaf 1694 // Calculate overall grade using correct penalty method
1695 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
516cf3eb 1696 }
516cf3eb 1697
47e16978 1698 // If the state was graded we need to ...
ca56222d 1699 if (question_state_is_graded($state)) {
47e16978 1700 // update the attempt grade
1701 $attempt->sumgrades -= (float)$state->last_graded->grade;
1702 $attempt->sumgrades += (float)$state->grade;
1703
1704 // and update the last_graded field.
ca56222d 1705 unset($state->last_graded);
1706 $state->last_graded = clone($state);
1707 unset($state->last_graded->changed);
1708 }
516cf3eb 1709 }
1710 $attempt->timemodified = $action->timestamp;
1711
1712 return true;
1713}
1714
1715/**
1716* Determine if event requires grading
1717*/
4f48fb42 1718function question_isgradingevent($event) {
f30bbcaf 1719 return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
516cf3eb 1720}
1721
516cf3eb 1722/**
1723* Applies the penalty from the previous graded responses to the raw grade
1724* for the current responses
1725*
1726* The grade for the question in the current state is computed by subtracting the
1727* penalty accumulated over the previous graded responses at the question from the
1728* raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
1729* the grade is set to zero. The ->grade field of the state object is modified to
1730* reflect the new grade but is never allowed to decrease.
1731* @param object $question The question for which the penalty is to be applied.
1732* @param object $state The state for which the grade is to be set from the
1733* raw grade and the cumulative penalty from the last
1734* graded state. The ->grade field is updated by applying
1735* the penalty scheme determined in $cmoptions to the ->raw_grade and
1736* ->last_graded->penalty fields.
1737* @param object $cmoptions The options set by the course module.
1738* The ->penaltyscheme field determines whether penalties
1739* for incorrect earlier responses are subtracted.
1740*/
4f48fb42 1741function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
b423cff1 1742 // TODO. Quiz dependancy. The fact that the attempt that is passed in here
1743 // is from quiz_attempts, and we use things like $cmoptions->timelimit.
3bee1ead 1744
f30bbcaf 1745 // deal with penalty
516cf3eb 1746 if ($cmoptions->penaltyscheme) {
47e16978 1747 $state->grade = $state->raw_grade - $state->sumpenalty;
1748 $state->sumpenalty += (float) $state->penalty;
516cf3eb 1749 } else {
1750 $state->grade = $state->raw_grade;
1751 }
1752
1753 // deal with timelimit
1754 if ($cmoptions->timelimit) {
1755 // We allow for 5% uncertainty in the following test
84e628a0 1756 if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 1.05) {
c7318911 1757 $cm = get_coursemodule_from_instance('quiz', $cmoptions->id);
1758 if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id),
1759 $attempt->userid, false)) {
1760 $state->grade = 0;
1761 }
516cf3eb 1762 }
1763 }
1764
1765 // deal with closing time
1766 if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
1767 and !$attempt->preview) { // ignore closing time for previews
1768 $state->grade = 0;
1769 }
1770
1771 // Ensure that the grade does not go down
1772 $state->grade = max($state->grade, $state->last_graded->grade);
1773}
1774
516cf3eb 1775/**
1776* Print the icon for the question type
1777*
3843d274 1778* @param object $question The question object for which the icon is required
1779* only $question->qtype is used.
1780* @param boolean $return If true the functions returns the link as a string
516cf3eb 1781*/
dcc2ffde 1782function print_question_icon($question, $return = false) {
dc1f00de 1783 global $QTYPES, $CFG;
516cf3eb 1784
b9d4b031 1785 if (array_key_exists($question->qtype, $QTYPES)) {
05efb0e5 1786 $namestr = $QTYPES[$question->qtype]->local_name();
b9d4b031 1787 } else {
1788 $namestr = 'missingtype';
1789 }
dcc2ffde 1790 $html = '<img src="' . $CFG->wwwroot . '/question/type/' .
1791 $question->qtype . '/icon.gif" alt="' .
1792 $namestr . '" title="' . $namestr . '" />';
516cf3eb 1793 if ($return) {
1794 return $html;
1795 } else {
1796 echo $html;
1797 }
1798}
1799
516cf3eb 1800/**
1801* Returns a html link to the question image if there is one
1802*
1803* @return string The html image tag or the empy string if there is no image.
1804* @param object $question The question object
1805*/
9fc3100f 1806function get_question_image($question) {
eb84a826 1807 global $CFG, $DB;
516cf3eb 1808 $img = '';
1809
eb84a826 1810 if (!$category = $DB->get_record('question_categories', array('id'=>$question->category))) {
d89ec019 1811 print_error('invalidcategory');
9fc3100f 1812 }
1813 $coursefilesdir = get_filesdir_from_context(get_context_instance_by_id($category->contextid));
1814
516cf3eb 1815 if ($question->image) {
1816
1817 if (substr(strtolower($question->image), 0, 7) == 'http://') {
1818 $img .= $question->image;
1819
516cf3eb 1820 } else {
5a254a29 1821 require_once($CFG->libdir .'/filelib.php');
bbf795df 1822 $img = get_file_url("$coursefilesdir/{$question->image}");
5a254a29 1823 }
516cf3eb 1824 }
1825 return $img;
1826}
b6e907a2 1827
e8f99abc 1828function question_print_comment_fields($question, $state, $prefix, $cmoptions, $caption = '') {
83f0fef5 1829 global $QTYPES;
766df8f7 1830 $idprefix = preg_replace('/[^-_a-zA-Z0-9]/', '', $prefix);
574c3750 1831 $otherquestionsinuse = '';
1832 if (!empty($cmoptions->questions)) {
1833 $otherquestionsinuse = $cmoptions->questions;
1834 }
1835 if (!question_state_is_graded($state) && $QTYPES[$question->qtype]->is_question_manual_graded($question, $otherquestionsinuse)) {
83f0fef5 1836 $grade = '';
1837 } else {
1838 $grade = question_format_grade($cmoptions, $state->last_graded->grade);
1839 }
766df8f7 1840 $maxgrade = question_format_grade($cmoptions, $question->maxgrade);
1841 $fieldsize = strlen($maxgrade) - 1;
e8f99abc 1842 if (empty($caption)) {
1843 $caption = format_string($question->name);
1844 }
1845 ?>
1846<fieldset class="que comment clearfix">
1847 <legend class="ftoggler"><?php echo $caption; ?></legend>
1848 <div class="fcontainer clearfix">
1849 <div class="fitem">
1850 <div class="fitemtitle">
1851 <label for="<?php echo $idprefix; ?>_comment_box"><?php print_string('comment', 'quiz'); ?></label>
1852 </div>
1853 <div class="felement fhtmleditor">
1854 <?php print_textarea(can_use_html_editor(), 15, 60, 630, 300, $prefix . '[comment]',
1855 $state->manualcomment, 0, false, $idprefix . '_comment_box'); ?>
1856 </div>
1857 </div>
1858 <div class="fitem">
1859 <div class="fitemtitle">
1860 <label for="<?php echo $idprefix; ?>_grade_field"><?php print_string('grade', 'quiz'); ?></label>
1861 </div>
1862 <div class="felement ftext">
1863 <input type="text" name="<?php echo $prefix; ?>[grade]" size="<?php echo $fieldsize; ?>" id="<?php echo $idprefix; ?>_grade_field" value="<?php echo $grade; ?>" /> / <?php echo $maxgrade; ?>
1864 </div>
1865 </div>
1866 </div>
1867</fieldset>
1868 <?php
b6e907a2 1869}
1870
994c8c35 1871/**
1872 * Process a manual grading action. That is, use $comment and $grade to update
1873 * $state and $attempt. The attempt and the comment text are stored in the
1874 * database. $state is only updated in memory, it is up to the call to store
1875 * that, if appropriate.
1876 *
1877 * @param object $question the question
1878 * @param object $state the state to be updated.
1879 * @param object $attempt the attempt the state belongs to, to be updated.
83f0fef5 1880 * @param string $comment the new comment from the teacher.
1881 * @param mixed $grade the grade the teacher assigned, or '' to not change the grade.
994c8c35 1882 * @return mixed true on success, a string error message if a problem is detected
1883 * (for example score out of range).
1884 */
b6e907a2 1885function question_process_comment($question, &$state, &$attempt, $comment, $grade) {
eb84a826 1886 global $DB;
1887
83f0fef5 1888 $grade = trim($grade);
994c8c35 1889 if ($grade < 0 || $grade > $question->maxgrade) {
1890 $a = new stdClass;
1891 $a->grade = $grade;
1892 $a->maxgrade = $question->maxgrade;
1893 $a->name = $question->name;
1894 return get_string('errormanualgradeoutofrange', 'question', $a);
1895 }
b6e907a2 1896
1897 // Update the comment and save it in the database
fca490bc 1898 $comment = trim($comment);
3e3e5a40 1899 $state->manualcomment = $comment;
eb84a826 1900 if (!$DB->set_field('question_sessions', 'manualcomment', $comment, array('attemptid'=>$attempt->uniqueid, 'questionid'=>$question->id))) {
994c8c35 1901 return get_string('errorsavingcomment', 'question', $question);
b6e907a2 1902 }
95ac8a40 1903
1904 // Update the attempt if the score has changed.
83f0fef5 1905 if ($grade !== '' && (abs($state->last_graded->grade - $grade) > 0.002 || $state->last_graded->event != QUESTION_EVENTMANUALGRADE)) {
b6e907a2 1906 $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
1907 $attempt->timemodified = time();
eb84a826 1908 if (!$DB->update_record('quiz_attempts', $attempt)) {
994c8c35 1909 return get_string('errorupdatingattempt', 'question', $attempt);
b6e907a2 1910 }
84bf852c 1911
1912 // We want to update existing state (rather than creating new one) if it
1913 // was itself created by a manual grading event.
83f0fef5 1914 $state->update = $state->event == QUESTION_EVENTMANUALGRADE;
84bf852c 1915
1916 // Update the other parts of the state object.
b6e907a2 1917 $state->raw_grade = $grade;
1918 $state->grade = $grade;
1919 $state->penalty = 0;
1920 $state->timestamp = time();
36be25f6 1921 $state->seq_number++;
95ac8a40 1922 $state->event = QUESTION_EVENTMANUALGRADE;
1923
b6e907a2 1924 // Update the last graded state (don't simplify!)
1925 unset($state->last_graded);
1926 $state->last_graded = clone($state);
95ac8a40 1927
1928 // We need to indicate that the state has changed in order for it to be saved.
1929 $state->changed = 1;
b6e907a2 1930 }
1931
994c8c35 1932 return true;
b6e907a2 1933}
1934
516cf3eb 1935/**
1936* Construct name prefixes for question form element names
1937*
1938* Construct the name prefix that should be used for example in the
1939* names of form elements created by questions.
4f48fb42 1940* This is called by {@link get_question_options()}
516cf3eb 1941* to set $question->name_prefix.
1942* This name prefix includes the question id which can be
4f48fb42 1943* extracted from it with {@link question_get_id_from_name_prefix()}.
516cf3eb 1944*
1945* @return string
1946* @param integer $id The question id
1947*/
4f48fb42 1948function question_make_name_prefix($id) {
516cf3eb 1949 return 'resp' . $id . '_';
1950}
1951
1952/**
f62040ed 1953 * Extract question id from the prefix of form element names
1954 *
1955 * @return integer The question id
1956 * @param string $name The name that contains a prefix that was
1957 * constructed with {@link question_make_name_prefix()}
1958 */
4f48fb42 1959function question_get_id_from_name_prefix($name) {
f62040ed 1960 if (!preg_match('/^resp([0-9]+)_/', $name, $matches)) {
516cf3eb 1961 return false;
f62040ed 1962 }
516cf3eb 1963 return (integer) $matches[1];
1964}
1965
f62040ed 1966/**
1967 * Extract question id from the prefix of form element names
1968 *
1969 * @return integer The question id
1970 * @param string $name The name that contains a prefix that was
1971 * constructed with {@link question_make_name_prefix()}
1972 */
1973function question_id_and_key_from_post_name($name) {
1974 if (!preg_match('/^resp([0-9]+)_(.*)$/', $name, $matches)) {
1975 return array(false, false);
1976 }
1977 return array((integer) $matches[1], $matches[2]);
1978}
1979
4f48fb42 1980/**
4dca7e51 1981 * Returns the unique id for a new attempt
1982 *
1983 * Every module can keep their own attempts table with their own sequential ids but
1984 * the question code needs to also have a unique id by which to identify all these
1985 * attempts. Hence a module, when creating a new attempt, calls this function and
1986 * stores the return value in the 'uniqueid' field of its attempts table.
4f48fb42 1987 */
36be25f6 1988function question_new_attempt_uniqueid($modulename='quiz') {
eb84a826 1989 global $DB;
1990
d7444d44 1991 $attempt = new stdClass;
36be25f6 1992 $attempt->modulename = $modulename;
eb84a826 1993 if (!$id = $DB->insert_record('question_attempts', $attempt)) {
d89ec019 1994 print_error('cannotcreate', 'question');
36be25f6 1995 }
1996 return $id;
516cf3eb 1997}
1998
36be25f6 1999/**
2000 * Creates a stamp that uniquely identifies this version of the question
cbe20043 2001 *
2002 * In future we want this to use a hash of the question data to guarantee that
2003 * identical versions have the same version stamp.
2004 *
2005 * @param object $question
2006 * @return string A unique version stamp
2007 */
2008function question_hash($question) {
2009 return make_unique_id_code();
2010}
2011
f88fb62c 2012/**
2013 * Round a grade to to the correct number of decimal places, and format it for display.
84e628a0 2014 * If $cmoptions->questiondecimalpoints is set, that is used, otherwise
2015 * else if $cmoptions->decimalpoints is used,
2016 * otherwise a default of 2 is used, but this should not be relied upon, and generated a developer debug warning.
2017 * However, if $cmoptions->questiondecimalpoints is -1, the means use $cmoptions->decimalpoints.
f88fb62c 2018 *
84e628a0 2019 * @param object $cmoptions The modules settings.
f88fb62c 2020 * @param float $grade The grade to round.
2021 */
2022function question_format_grade($cmoptions, $grade) {
84e628a0 2023 if (isset($cmoptions->questiondecimalpoints) && $cmoptions->questiondecimalpoints != -1) {
2024 $decimalplaces = $cmoptions->questiondecimalpoints;
2025 } else if (isset($cmoptions->decimalpoints)) {
2026 $decimalplaces = $cmoptions->decimalpoints;
2027 } else {
2028 $decimalplaces = 2;
2029 debugging('Code that leads to question_format_grade being called should set ' .
2030 '$cmoptions->questiondecimalpoints or $cmoptions->decimalpoints', DEBUG_DEVELOPER);
2031 }
2032 return format_float($grade, $decimalplaces);
f88fb62c 2033}
516cf3eb 2034
62e76c67 2035/**
2036 * @return string An inline script that creates a JavaScript object storing
2037 * various strings and bits of configuration that the scripts in qengine.js need
2038 * to get from PHP.
2039 */
2040function question_init_qenginejs_script() {
2041 global $CFG;
62e76c67 2042 $config = array(
2043 'pixpath' => $CFG->pixpath,
2044 'wwwroot' => $CFG->wwwroot,
2045 'flagtooltip' => get_string('clicktoflag', 'question'),
2046 'unflagtooltip' => get_string('clicktounflag', 'question'),
2047 'flaggedalt' => get_string('flagged', 'question'),
2048 'unflaggedalt' => get_string('notflagged', 'question'),
2049 );
d583bff0 2050 return print_js_config($config, 'qengine_config', true);
62e76c67 2051}
2052
516cf3eb 2053/// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
99ba746d 2054/**
2055 * Get the HTML that needs to be included in the head tag when the
2056 * questions in $questionlist are printed in the gives states.
2057 *
2058 * @param array $questionlist a list of questionids of the questions what will appear on this page.
2059 * @param array $questions an array of question objects, whose keys are question ids.
2060 * Must contain all the questions in $questionlist
2061 * @param array $states an array of question state objects, whose keys are question ids.
2062 * Must contain the state of all the questions in $questionlist
2063 *
2064 * @return string some HTML code that can go inside the head tag.
2065 */
e1dc480c 2066function get_html_head_contributions($questionlist, &$questions, &$states) {
62e76c67 2067 global $CFG, $QTYPES;
2068
2069 // The question engine's own JavaScript.
2070 require_js(array('yui_yahoo','yui_event', 'yui_connection'));
ac324e05 2071 require_js('question/qengine.js');
516cf3eb 2072
62e76c67 2073 // An inline script to record various lang strings, etc. that qengine.js needs.
2074 $contributions = array(question_init_qenginejs_script());
2075
2076 // Anything that questions on this page need.
99ba746d 2077 foreach ($questionlist as $questionid) {
2078 $question = $questions[$questionid];
2079 $contributions = array_merge($contributions,
2080 $QTYPES[$question->qtype]->get_html_head_contributions(
2081 $question, $states[$questionid]));
2082 }
62e76c67 2083
99ba746d 2084 return implode("\n", array_unique($contributions));
2085}
2086
50da63eb 2087/**
2088 * Like @see{get_html_head_contributions} but for the editing page
2089 * question/question.php.
2090 *
2091 * @param $question A question object. Only $question->qtype is used.
2092 * @return string some HTML code that can go inside the head tag.
2093 */
2094function get_editing_head_contributions($question) {
2095 global $QTYPES;
2096 $contributions = $QTYPES[$question->qtype]->get_editing_head_contributions();
2097 return implode("\n", array_unique($contributions));
2098}
2099
99ba746d 2100/**
4dca7e51 2101 * Prints a question
2102 *
2103 * Simply calls the question type specific print_question() method.
2104 * @param object $question The question to be rendered.
2105 * @param object $state The state to render the question in.
2106 * @param integer $number The number for this question.
2107 * @param object $cmoptions The options specified by the course module
2108 * @param object $options An object specifying the rendering options.
2109 */
4f48fb42 2110function print_question(&$question, &$state, $number, $cmoptions, $options=null) {
f02c6f01 2111 global $QTYPES;
426091ee 2112 $QTYPES[$question->qtype]->print_question($question, $state, $number, $cmoptions, $options);
516cf3eb 2113}
f5565b69 2114/**
99ba746d 2115 * Saves question options
2116 *
2117 * Simply calls the question type specific save_question_options() method.
2118 */
f5565b69 2119function save_question_options($question) {
2120 global $QTYPES;
2121
2122 $QTYPES[$question->qtype]->save_question_options($question);
2123}
516cf3eb 2124
2125/**
2126* Gets all teacher stored answers for a given question
2127*
2128* Simply calls the question type specific get_all_responses() method.
2129*/
2130// ULPGC ecastro
4f48fb42 2131function get_question_responses($question, $state) {
f02c6f01 2132 global $QTYPES;
2133 $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
516cf3eb 2134 return $r;
2135}
2136
516cf3eb 2137/**
dc1f00de 2138* Gets the response given by the user in a particular state
516cf3eb 2139*
2140* Simply calls the question type specific get_actual_response() method.
2141*/
2142// ULPGC ecastro
4f48fb42 2143function get_question_actual_response($question, $state) {
f02c6f01 2144 global $QTYPES;
516cf3eb 2145
f02c6f01 2146 $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
516cf3eb 2147 return $r;
2148}
2149
2150/**
dc1f00de 2151* TODO: document this
516cf3eb 2152*/
2153// ULPGc ecastro
4f48fb42 2154function get_question_fraction_grade($question, $state) {
f02c6f01 2155 global $QTYPES;
516cf3eb 2156
f02c6f01 2157 $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
516cf3eb 2158 return $r;
2159}
6f51ed72 2160/**
2161* @return integer grade out of 1 that a random guess by a student might score.
2162*/
2163// ULPGc ecastro
455c3efa 2164function question_get_random_guess_score($question) {
6f51ed72 2165 global $QTYPES;
2166
2167 $r = $QTYPES[$question->qtype]->get_random_guess_score($question);
2168 return $r;
2169}
516cf3eb 2170/// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
2171
6cb4910c 2172/**
2173 * returns the categories with their names ordered following parent-child relationships
2174 * finally it tries to return pending categories (those being orphaned, whose parent is
2175 * incorrect) to avoid missing any category from original array.
2176 */
2177function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
eb84a826 2178 global $DB;
2179
6cb4910c 2180 $children = array();
2181 $keys = array_keys($categories);
2182
2183 foreach ($keys as $key) {
2184 if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
2185 $children[$key] = $categories[$key];
2186 $categories[$key]->processed = true;
2187 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
2188 }
2189 }
2190 //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
2191 if ($level == 1) {
2192 foreach ($keys as $key) {
2193 //If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
eb84a826 2194 if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories', array('course'=>$categories[$key]->course, 'id'=>$categories[$key]->parent))) {
6cb4910c 2195 $children[$key] = $categories[$key];
2196 $categories[$key]->processed = true;
2197 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
2198 }
2199 }
2200 }
2201 return $children;
2202}
2203
2204/**
062a7522 2205 * Private method, only for the use of add_indented_names().
271ffe3f 2206 *
062a7522 2207 * Recursively adds an indentedname field to each category, starting with the category
271ffe3f 2208 * with id $id, and dealing with that category and all its children, and
062a7522 2209 * return a new array, with those categories in the right order.
2210 *
271ffe3f 2211 * @param array $categories an array of categories which has had childids
062a7522 2212 * fields added by flatten_category_tree(). Passed by reference for
2213 * performance only. It is not modfied.
2214 * @param int $id the category to start the indenting process from.
2215 * @param int $depth the indent depth. Used in recursive calls.
2216 * @return array a new array of categories, in the right order for the tree.
6cb4910c 2217 */
3bee1ead 2218function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
271ffe3f 2219
062a7522 2220 // Indent the name of this category.
2221 $newcategories = array();
2222 $newcategories[$id] = $categories[$id];
2223 $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
271ffe3f 2224
062a7522 2225 // Recursively indent the children.
2226 foreach ($categories[$id]->childids as $childid) {
3bee1ead 2227 if ($childid != $nochildrenof){
2228 $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1, $nochildrenof);
2229 }
6cb4910c 2230 }
271ffe3f 2231
062a7522 2232 // Remove the childids array that were temporarily added.
2233 unset($newcategories[$id]->childids);
271ffe3f 2234
062a7522 2235 return $newcategories;
6cb4910c 2236}
2237
2238/**
062a7522 2239 * Format categories into an indented list reflecting the tree structure.
271ffe3f 2240 *
062a7522 2241 * @param array $categories An array of category objects, for example from the.
2242 * @return array The formatted list of categories.
6cb4910c 2243 */
3bee1ead 2244function add_indented_names($categories, $nochildrenof = -1) {
6cb4910c 2245
271ffe3f 2246 // Add an array to each category to hold the child category ids. This array will be removed
062a7522 2247 // again by flatten_category_tree(). It should not be used outside these two functions.
2248 foreach (array_keys($categories) as $id) {
2249 $categories[$id]->childids = array();
6cb4910c 2250 }
2251
062a7522 2252 // Build the tree structure, and record which categories are top-level.
271ffe3f 2253 // We have to be careful, because the categories array may include published
062a7522 2254 // categories from other courses, but not their parents.
2255 $toplevelcategoryids = array();
2256 foreach (array_keys($categories) as $id) {
2257 if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
2258 $categories[$categories[$id]->parent]->childids[] = $id;
2259 } else {
2260 $toplevelcategoryids[] = $id;
6cb4910c 2261 }
2262 }
2263
062a7522 2264 // Flatten the tree to and add the indents.
2265 $newcategories = array();
2266 foreach ($toplevelcategoryids as $id) {
3bee1ead 2267 $newcategories = $newcategories + flatten_category_tree($categories, $id, 0, $nochildrenof);
6cb4910c 2268 }
2269
062a7522 2270 return $newcategories;
6cb4910c 2271}
2dd6d66b 2272
516cf3eb 2273/**
062a7522 2274 * Output a select menu of question categories.
271ffe3f 2275 *
062a7522 2276 * Categories from this course and (optionally) published categories from other courses
271ffe3f 2277 * are included. Optionally, only categories the current user may edit can be included.
062a7522 2278 *
2279 * @param integer $courseid the id of the course to get the categories for.
2280 * @param integer $published if true, include publised categories from other courses.
2281 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
2282 * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
2283 */
3bee1ead 2284function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) {
2285 $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof);
3ed998f3 2286 if ($selected) {
2287 $nothing = '';
2288 } else {
2289 $nothing = 'choose';
516cf3eb 2290 }
3bee1ead 2291 choose_from_menu_nested($categoriesarray, 'category', $selected, $nothing);
516cf3eb 2292}
2293
cca6e300 2294/**
2295 * @param integer $contextid a context id.
2296 * @return object the default question category for that context, or false if none.
2297 */
2298function question_get_default_category($contextid) {
2299 global $DB;
2300 $category = $DB->get_records('question_categories', array('contextid' => $contextid),'id','*',0,1);
2301 if (!empty($category)) {
2302 return reset($category);
2303 } else {
2304 return false;
2305 }
2306}
2307
2308/**
2309 * @param object $context a context
2310 * @return string A URL for editing questions in this context.
2311 */
2312function question_edit_url($context) {
2313 global $CFG, $SITE;
2314 if (!has_any_capability(question_get_question_capabilities(), $context)) {
2315 return false;
2316 }
2317 $baseurl = $CFG->wwwroot . '/question/edit.php?';
2318 $defaultcategory = question_get_default_category($context->id);
2319 if ($defaultcategory) {
bdff6b85 2320 $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&amp;';
cca6e300 2321 }
2322 switch ($context->contextlevel) {
2323 case CONTEXT_SYSTEM:
2324 return $baseurl . 'courseid=' . $SITE->id;
2325 case CONTEXT_COURSECAT:
2326 // This is nasty, becuase we can only edit questions in a course
2327 // context at the moment, so for now we just return false.
2328 return false;
2329 case CONTEXT_COURSE:
2330 return $baseurl . 'courseid=' . $context->instanceid;
2331 case CONTEXT_MODULE:
2332 return $baseurl . 'cmid=' . $context->instanceid;
2333 }
2334
2335}
2336
8f0f605d 2337/**
2338* Gets the default category in the most specific context.
2339* If no categories exist yet then default ones are created in all contexts.
2340*
2341* @param array $contexts The context objects for this context and all parent contexts.
2342* @return object The default category - the category in the course context
2343*/
2344function question_make_default_categories($contexts) {
eb84a826 2345 global $DB;
353b2d70 2346 static $preferredlevels = array(
2347 CONTEXT_COURSE => 4,
2348 CONTEXT_MODULE => 3,
2349 CONTEXT_COURSECAT => 2,
2350 CONTEXT_SYSTEM => 1,
2351 );
eb84a826 2352
8f0f605d 2353 $toreturn = null;
353b2d70 2354 $preferredness = 0;
8f0f605d 2355 // If it already exists, just return it.
2356 foreach ($contexts as $key => $context) {
353b2d70 2357 if (!$exists = $DB->record_exists("question_categories", array('contextid'=>$context->id))) {
eb84a826 2358 // Otherwise, we need to make one
2359 $category = new stdClass;
2360 $contextname = print_context_name($context, false, true);
2361 $category->name = get_string('defaultfor', 'question', $contextname);
2362 $category->info = get_string('defaultinfofor', 'question', $contextname);
2363 $category->contextid = $context->id;
2364 $category->parent = 0;
2365 $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically.
2366 $category->stamp = make_unique_id_code();
2367 if (!$category->id = $DB->insert_record('question_categories', $category)) {
2368 print_error('cannotcreatedefaultcat', '', '', print_context_name($context));
8f0f605d 2369 }
b9bd6da4 2370 } else {
cca6e300 2371 $category = question_get_default_category($context->id);
8f0f605d 2372 }
80f7bafd 2373 if ($preferredlevels[$context->contextlevel] > $preferredness &&
2374 has_any_capability(array('moodle/question:usemine', 'moodle/question:useall'), $context)) {
353b2d70 2375 $toreturn = $category;
2376 $preferredness = $preferredlevels[$context->contextlevel];
8f0f605d 2377 }
2378 }
2379
353b2d70 2380 if (!is_null($toreturn)) {
2381 $toreturn = clone($toreturn);
2382 }
8f0f605d 2383 return $toreturn;
2384}
2385
375ed78a 2386/**
3bee1ead 2387 * Get all the category objects, including a count of the number of questions in that category,
2388 * for all the categories in the lists $contexts.
375ed78a 2389 *
3bee1ead 2390 * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
2391 * @param string $sortorder used as the ORDER BY clause in the select statement.
2392 * @return array of category objects.
375ed78a 2393 */
3bee1ead 2394function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
eb84a826 2395 global $DB;
2396 return $DB->get_records_sql("
630a3dc3 2397 SELECT c.*, (SELECT count(1) FROM {question} q
eb84a826 2398 WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
2399 FROM {question_categories} c
2400 WHERE c.contextid IN ($contexts)
2401 ORDER BY $sortorder");
3bee1ead 2402}
3a3c454e 2403
3bee1ead 2404/**
2405 * Output an array of question categories.
2406 */
2407function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) {
2408 global $CFG;
2409 $pcontexts = array();
2410 foreach($contexts as $context){
2411 $pcontexts[] = $context->id;
61d9b9fc 2412 }
3bee1ead 2413 $contextslist = join($pcontexts, ', ');
2414
2415 $categories = get_categories_for_contexts($contextslist);
375ed78a 2416
3bee1ead 2417 $categories = question_add_context_in_key($categories);
2418
2419 if ($top){
2420 $categories = question_add_tops($categories, $pcontexts);
2421 }
2422 $categories = add_indented_names($categories, $nochildrenof);
3ed998f3 2423
3bee1ead 2424 //sort cats out into different contexts
375ed78a 2425 $categoriesarray = array();
3bee1ead 2426 foreach ($pcontexts as $pcontext){
2427 $contextstring = print_context_name(get_context_instance_by_id($pcontext), true, true);
2428 foreach ($categories as $category) {
2429 if ($category->contextid == $pcontext){
2430 $cid = $category->id;
2431 if ($currentcat!= $cid || $currentcat==0) {
1e05bf54 2432 $countstring = (!empty($category->questioncount))?" ($category->questioncount)":'';
3bee1ead 2433 $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
2434 }
2435 }
375ed78a 2436 }
2437 }
3bee1ead 2438 if ($popupform){
2439 $popupcats = array();
2440 foreach ($categoriesarray as $contextstring => $optgroup){
2441 $popupcats[] = '--'.$contextstring;
2442 $popupcats = array_merge($popupcats, $optgroup);
2443 $popupcats[] = '--';
2444 }
2445 return $popupcats;
2446 } else {
2447 return $categoriesarray;
2448 }
375ed78a 2449}
2450
3bee1ead 2451function question_add_context_in_key($categories){
2452 $newcatarray = array();
2453 foreach ($categories as $id => $category) {
2454 $category->parent = "$category->parent,$category->contextid";
2455 $category->id = "$category->id,$category->contextid";
2456 $newcatarray["$id,$category->contextid"] = $category;
516cf3eb 2457 }
3bee1ead 2458 return $newcatarray;
2459}
2460function question_add_tops($categories, $pcontexts){
2461 $topcats = array();
2462 foreach ($pcontexts as $context){
2463 $newcat = new object();
2464 $newcat->id = "0,$context";
2465 $newcat->name = get_string('top');
2466 $newcat->parent = -1;
2467 $newcat->contextid = $context;
2468 $topcats["0,$context"] = $newcat;
2469 }
2470 //put topcats in at beginning of array - they'll be sorted into different contexts later.
2471 return array_merge($topcats, $categories);
516cf3eb 2472}
2473
516cf3eb 2474/**
b20ea669 2475 * Returns a comma separated list of ids of the category and all subcategories
2476 */
dc1f00de 2477function question_categorylist($categoryid) {
eb84a826 2478 global $DB;
2479
516cf3eb 2480 // returns a comma separated list of ids of the category and all subcategories
2481 $categorylist = $categoryid;
df4e2244 2482 if ($subcategories = $DB->get_records('question_categories', array('parent'=>$categoryid), 'sortorder ASC', 'id, 1')) {
516cf3eb 2483 foreach ($subcategories as $subcategory) {
dc1f00de 2484 $categorylist .= ','. question_categorylist($subcategory->id);
516cf3eb 2485 }
2486 }
2487 return $categorylist;
2488}
2489
f5c15407 2490
3bee1ead 2491
516cf3eb 2492
947217d7 2493//===========================
2494// Import/Export Functions
2495//===========================
2496
ff4b6492 2497/**
2498 * Get list of available import or export formats
2499 * @param string $type 'import' if import list, otherwise export list assumed
271ffe3f 2500 * @return array sorted list of import/export formats available
ff4b6492 2501**/
2502function get_import_export_formats( $type ) {
2503
2504 global $CFG;
dc1f00de 2505 $fileformats = get_list_of_plugins("question/format");
ff4b6492 2506
2507 $fileformatname=array();
947217d7 2508 require_once( "{$CFG->dirroot}/question/format.php" );
ff4b6492 2509 foreach ($fileformats as $key => $fileformat) {
2510 $format_file = $CFG->dirroot . "/question/format/$fileformat/format.php";
2511 if (file_exists( $format_file ) ) {
2512 require_once( $format_file );
2513 }
2514 else {
2515 continue;
2516 }
70c01adb 2517 $classname = "qformat_$fileformat";
ff4b6492 2518 $format_class = new $classname();
2519 if ($type=='import') {
2520 $provided = $format_class->provide_import();
2521 }
2522 else {
2523 $provided = $format_class->provide_export();
2524 }
2525 if ($provided) {
2526 $formatname = get_string($fileformat, 'quiz');
2527 if ($formatname == "[[$fileformat]]") {
2528 $formatname = $fileformat; // Just use the raw folder name
2529 }
2530 $fileformatnames[$fileformat] = $formatname;
2531 }
2532 }
2533 natcasesort($fileformatnames);
2534
2535 return $fileformatnames;
2536}
feb60a07 2537
2538
2539/**
2540* Create default export filename
2541*
2542* @return string default export filename
2543* @param object $course
2544* @param object $category
2545*/
2546function default_export_filename($course,$category) {
2547 //Take off some characters in the filename !!
2548 $takeoff = array(" ", ":", "/", "\\", "|");
57f1b914 2549 $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz")));
feb60a07 2550 //If non-translated, use "export"
2551 if (substr($export_word,0,1) == "[") {
2552 $export_word= "export";
2553 }
2554
2555 //Calculate the date format string
2556 $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz"));
2557 //If non-translated, use "%Y%m%d-%H%M"
2558 if (substr($export_date_format,0,1) == "[") {
2559 $export_date_format = "%%Y%%m%%d-%%H%%M";
2560 }
2561
2562 //Calculate the shortname
2563 $export_shortname = clean_filename($course->shortname);
2564 if (empty($export_shortname) or $export_shortname == '_' ) {
2565 $export_shortname = $course->id;
2566 }
2567
2568 //Calculate the category name
2569 $export_categoryname = clean_filename($category->name);
2570
2571 //Calculate the final export filename
2572 //The export word
2573 $export_name = $export_word."-";
2574 //The shortname
57f1b914 2575 $export_name .= moodle_strtolower($export_shortname)."-";
feb60a07 2576 //The category name
57f1b914 2577 $export_name .= moodle_strtolower($export_categoryname)."-";
feb60a07 2578 //The date format
2579 $export_name .= userdate(time(),$export_date_format,99,false);
6c58e198 2580 //Extension is supplied by format later.
feb60a07 2581
2582 return $export_name;
2583}
3bee1ead 2584class context_to_string_translator{
2585 /**
2586 * @var array used to translate between contextids and strings for this context.
2587 */
2588 var $contexttostringarray = array();
2589
2590 function context_to_string_translator($contexts){
2591 $this->generate_context_to_string_array($contexts);
2592 }
2593
2594 function context_to_string($contextid){
2595 return $this->contexttostringarray[$contextid];
2596 }
2597
2598 function string_to_context($contextname){
2599 $contextid = array_search($contextname, $this->contexttostringarray);
2600 return $contextid;
2601 }
2602
2603 function generate_context_to_string_array($contexts){
2604 if (!$this->contexttostringarray){
2605 $catno = 1;
2606 foreach ($contexts as $context){
2607 switch ($context->contextlevel){
2608 case CONTEXT_MODULE :
2609 $contextstring = 'module';
2610 break;
2611 case CONTEXT_COURSE :
2612 $contextstring = 'course';
2613 break;
2614 case CONTEXT_COURSECAT :
2615 $contextstring = "cat$catno";
2616 $catno++;
2617 break;
2618 case CONTEXT_SYSTEM :
2619 $contextstring = 'system';
2620 break;
2621 }
2622 $this->contexttostringarray[$context->id] = $contextstring;
2623 }
2624 }
2625 }
2626
2627}
feb60a07 2628
cca6e300 2629/**
2630 * @return array all the capabilities that relate to accessing particular questions.
2631 */
2632function question_get_question_capabilities() {
2633 return array(
2634 'moodle/question:add',
2635 'moodle/question:editmine',
2636 'moodle/question:editall',
2637 'moodle/question:viewmine',
2638 'moodle/question:viewall',
2639 'moodle/question:usemine',
2640 'moodle/question:useall',
2641 'moodle/question:movemine',
2642 'moodle/question:moveall',
2643 );
2644}
2645
2646/**
2647 * @return array all the question bank capabilities.
2648 */
2649function question_get_all_capabilities() {
2650 $caps = question_get_question_capabilities();
2651 $caps[] = 'moodle/question:managecategory';
2652 $caps[] = 'moodle/question:flag';
2653 return $caps;
2654}
3bee1ead 2655
2656/**
2657 * Check capability on category
2658 * @param mixed $question object or id
2659 * @param string $cap 'add', 'edit', 'view', 'use', 'move'
2660 * @param integer $cachecat useful to cache all question records in a category
2661 * @return boolean this user has the capability $cap for this question $question?
2662 */
2663function question_has_capability_on($question, $cap, $cachecat = -1){
eb84a826 2664 global $USER, $DB;
2665
415f8c3e 2666 // nicolasconnault@gmail.com In some cases I get $question === false. Since no such object exists, it can't be deleted, we can safely return true
2667 if ($question === false) {
2668 return true;
2669 }
2670
3bee1ead 2671 // these are capabilities on existing questions capabilties are
2672 //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
2673 $question_questioncaps = array('edit', 'view', 'use', 'move');
3bee1ead 2674 static $questions = array();
2675 static $categories = array();
2676 static $cachedcat = array();
9f1017a0 2677 if ($cachecat != -1 && (array_search($cachecat, $cachedcat)===FALSE)){
eb84a826 2678 $questions += $DB->get_records('question', array('category'=>$cachecat));
3bee1ead 2679 $cachedcat[] = $cachecat;
2680 }
2681 if (!is_object($question)){
2682 if (!isset($questions[$question])){
f3409e53 2683 if (!$questions[$question] = $DB->get_record('question', array('id'=>$question), 'id,category,createdby')) {
0f6475b3 2684 print_error('questiondoesnotexist', 'question');
3bee1ead 2685 }
2686 }
2687 $question = $questions[$question];
2688 }
2689 if (!isset($categories[$question->category])){
eb84a826 2690 if (!$categories[$question->category] = $DB->get_record('question_categories', array('id'=>$question->category))) {
3bee1ead 2691 print_error('invalidcategory', 'quiz');
2692 }
2693 }
2694 $category = $categories[$question->category];
2695
2696 if (array_search($cap, $question_questioncaps)!== FALSE){
2697 if (!has_capability('moodle/question:'.$cap.'all', get_context_instance_by_id($category->contextid))){
2698 if ($question->createdby == $USER->id){
2699 return has_capability('moodle/question:'.$cap.'mine', get_context_instance_by_id($category->contextid));
2700 } else {
2701 return false;
2702 }
2703 } else {
2704 return true;
2705 }
2706 } else {
2707 return has_capability('moodle/question:'.$cap, get_context_instance_by_id($category->contextid));
2708 }
2709
2710}
2711
2712/**
2713 * Require capability on question.
2714 */
2715function question_require_capability_on($question, $cap){
2716 if (!question_has_capability_on($question, $cap)){
2717 print_error('nopermissions', '', '', $cap);
2718 }
2719 return true;
2720}
2721
2722function question_file_links_base_url($courseid){
2723 global $CFG;
2724 $baseurl = preg_quote("$CFG->wwwroot/file.php", '!');
2725 $baseurl .= '('.preg_quote('?file=', '!').')?';//may or may not
2726 //be using slasharguments, accept either
2727 $baseurl .= "/$courseid/";//course directory
2728 return $baseurl;
2729}
2730
2731/*
2732 * Find all course / site files linked to in a piece of html.
2733 * @param string html the html to search
2734 * @param int course search for files for courseid course or set to siteid for
2735 * finding site files.
2736 * @return array files with keys being files.
2737 */
2738function question_find_file_links_from_html($html, $courseid){
2739 global $CFG;
2740 $baseurl = question_file_links_base_url($courseid);
2741 $searchfor = '!'.
2742 '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$baseurl.'([^"]*)"'.
2743 '|'.
2744 '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$baseurl.'([^\']*)\''.
2745 '!i';
2746 $matches = array();
2747 $no = preg_match_all($searchfor, $html, $matches);
2748 if ($no){
2749 $rawurls = array_filter(array_merge($matches[5], $matches[10]));//array_filter removes empty elements
2750 //remove any links that point somewhere they shouldn't
2751 foreach (array_keys($rawurls) as $rawurlkey){
2752 if (!$cleanedurl = question_url_check($rawurls[$rawurlkey])){
2753 unset($rawurls[$rawurlkey]);
2754 } else {
2755 $rawurls[$rawurlkey] = $cleanedurl;
2756 }
2757
2758 }
2759 $urls = array_flip($rawurls);// array_flip removes duplicate files
2760 // and when we merge arrays will continue to automatically remove duplicates
2761 } else {
2762 $urls = array();
2763 }
2764 return $urls;
2765}
eb84a826 2766
2767/**
3bee1ead 2768 * Check that url doesn't point anywhere it shouldn't
2769 *
2770 * @param $url string relative url within course files directory
2771 * @return mixed boolean false if not OK or cleaned URL as string if OK
2772 */
2773function question_url_check($url){
2774 global $CFG;
2775 if ((substr(strtolower($url), 0, strlen($CFG->moddata)) == strtolower($CFG->moddata)) ||
2776 (substr(strtolower($url), 0, 10) == 'backupdata')){
2777 return false;
2778 } else {
2779 return clean_param($url, PARAM_PATH);
2780 }
2781}
2782
eb84a826 2783/**
3bee1ead 2784 * Find all course / site files linked to in a piece of html.
2785 * @param string html the html to search
2786 * @param int course search for files for courseid course or set to siteid for
2787 * finding site files.
2788 * @return array files with keys being files.
2789 */
2790function question_replace_file_links_in_html($html, $fromcourseid, $tocourseid, $url, $destination, &$changed){
2791 global $CFG;
5a254a29 2792 require_once($CFG->libdir .'/filelib.php');
2793 $tourl = get_file_url("$tocourseid/$destination");
3bee1ead 2794 $fromurl = question_file_links_base_url($fromcourseid).preg_quote($url, '!');
2795 $searchfor = array('!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$fromurl.'(")!i',
2796 '!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$fromurl.'(\')!i');
2797 $newhtml = preg_replace($searchfor, '\\1'.$tourl.'\\5', $html);
2798 if ($newhtml != $html){
2799 $changed = true;
2800 }
2801 return $newhtml;
2802}
2803
2804function get_filesdir_from_context($context){
eb84a826 2805 global $DB;
2806
3bee1ead 2807 switch ($context->contextlevel){
2808 case CONTEXT_COURSE :
2809 $courseid = $context->instanceid;
2810 break;
2811 case CONTEXT_MODULE :
eb84a826 2812 $courseid = $DB->get_field('course_modules', 'course', array('id'=>$context->instanceid));
3bee1ead 2813 break;
2814 case CONTEXT_COURSECAT :
2815 case CONTEXT_SYSTEM :
2816 $courseid = SITEID;
2817 break;
2818 default :
d89ec019 2819 print_error('invalidcontext');
3bee1ead 2820 }
2821 return $courseid;
2822}
4f5ffac0 2823/**
43ec99aa 2824 * Get the real state - the correct question id and answer - for a random
2825 * question.
4f5ffac0 2826 * @param object $state with property answer.
2827 * @return mixed return integer real question id or false if there was an
2828 * error..
2829 */
43ec99aa 2830function question_get_real_state($state){
2831 $realstate = clone($state);
4f5ffac0 2832 $matches = array();
91ec2c42 2833 if (!preg_match('|^random([0-9]+)-(.*)|', $state->answer, $matches)){
4f5ffac0 2834 notify(get_string('errorrandom', 'quiz_statistics'));
2835 return false;
2836 } else {
43ec99aa 2837 $realstate->question = $matches[1];
2838 $realstate->answer = $matches[2];
2839 return $realstate;
4f5ffac0 2840 }
2841}
43ec99aa 2842
62e76c67 2843/**
2844 * Update the flagged state of a particular question session.
2845 *
2846 * @param integer $sessionid question_session id.
2847 * @param boolean $newstate the new state for the flag.
2848 * @return boolean success or failure.
2849 */
2850function question_update_flag($sessionid, $newstate) {
2851 global $DB;
2852 return $DB->set_field('question_sessions', 'flagged', $newstate, array('id' => $sessionid));
2853}
2854
f62040ed 2855/**
2856 * Update the flagged state of all the questions in an attempt, where a new .
2857 *
2858 * @param integer $sessionid question_session id.
2859 * @param boolean $newstate the new state for the flag.
2860 * @return boolean success or failure.
2861 */
2862function question_save_flags($formdata, $attemptid, $questionids) {
2863 global $DB;
2864 $donequestionids = array();
2865 foreach ($formdata as $postvariable => $value) {
2866 list($qid, $key) = question_id_and_key_from_post_name($postvariable);
2867 if ($qid !== false && in_array($qid, $questionids)) {
2868 if ($key == '_flagged') {
2869 $DB->set_field('question_sessions', 'flagged', !empty($value),
2870 array('attemptid' => $attemptid, 'questionid' => $qid));
2871 $donequestionids[$qid] = 1;
2872 }
2873 }
2874 }
2875 foreach ($questionids as $qid) {
2876 if (!isset($donequestionids[$qid])) {
2877 $DB->set_field('question_sessions', 'flagged', 0,
2878 array('attemptid' => $attemptid, 'questionid' => $qid));
2879 }
2880 }
2881}
2882
62e76c67 2883/**
2884 * @param integer $attemptid the question_attempt id.
2885 * @param integer $questionid the question id.
2886 * @param integer $sessionid the question_session id.
2887 * @param object $user a user, or null to use $USER.
2888 * @return string that needs to be sent to question/toggleflag.php for it to work.
2889 */
2890function question_get_toggleflag_checksum($attemptid, $questionid, $sessionid, $user = null) {
2891 if (is_null($user)) {
2892 global $USER;
2893 $user = $USER;
2894 }
2895 return md5($attemptid . "_" . $user->secret . "_" . $questionid . "_" . $sessionid);
2896}
2897
f5c15407 2898?>