Refactored printing of feedback images and printing of feedback html classes. See...
[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 *
13 * @version $Id$
14 * @author Martin Dougiamas and many others. This has recently been completely
15 * rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of
16 * the Serving Mathematics project
17 * {@link http://maths.york.ac.uk/serving_maths}
18 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
19 * @package question
20 */
21
22/// CONSTANTS ///////////////////////////////////
516cf3eb 23
e56a08dc 24/**#@+
6b11a0e8 25 * The different types of events that can create question states
26 */
f30bbcaf 27define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle
28define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used)
29define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated
30define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle.
31define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously
32define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded.
33define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle.
34define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked
35define('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 36define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher
e56a08dc 37/**#@-*/
38
39/**#@+
a2156789 40 * The core question types.
6b11a0e8 41 */
60407982 42define("SHORTANSWER", "shortanswer");
43define("TRUEFALSE", "truefalse");
44define("MULTICHOICE", "multichoice");
45define("RANDOM", "random");
46define("MATCH", "match");
47define("RANDOMSAMATCH", "randomsamatch");
48define("DESCRIPTION", "description");
49define("NUMERICAL", "numerical");
50define("MULTIANSWER", "multianswer");
51define("CALCULATED", "calculated");
52define("RQP", "rqp");
53define("ESSAY", "essay");
e56a08dc 54/**#@-*/
55
6b11a0e8 56/**
57 * Constant determines the number of answer boxes supplied in the editing
58 * form for multiple choice and similar question types.
59 */
4f48fb42 60define("QUESTION_NUMANS", "10");
e56a08dc 61
1b8a7434 62/**
63 * The options used when popping up a question preview window in Javascript.
64 */
65define('QUESTION_PREVIEW_POPUP_OPTIONS', "'scrollbars=yes,resizable=yes,width=700,height=540'");
516cf3eb 66
6b11a0e8 67/**#@+
68 * Option flags for ->optionflags
69 * The options are read out via bitwise operation using these constants
70 */
71/**
72 * Whether the questions is to be run in adaptive mode. If this is not set then
73 * a question closes immediately after the first submission of responses. This
74 * is how question is Moodle always worked before version 1.5
75 */
76define('QUESTION_ADAPTIVE', 1);
77
6b11a0e8 78/**#@-*/
79
f02c6f01 80/// QTYPES INITIATION //////////////////
a2156789 81// These variables get initialised via calls to question_register_questiontype
82// as the question type classes are included.
83global $QTYPES, $QTYPE_MENU, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
516cf3eb 84/**
6b11a0e8 85 * Array holding question type objects
86 */
a2156789 87$QTYPES = array();
ccccf04f 88/**
6b11a0e8 89 * Array of question types names translated to the user's language
90 *
91 * The $QTYPE_MENU array holds the names of all the question types that the user should
92 * be able to create directly. Some internal question types like random questions are excluded.
93 * The complete list of question types can be found in {@link $QTYPES}.
94 */
a2156789 95$QTYPE_MENU = array();
96/**
97 * String in the format "'type1','type2'" that can be used in SQL clauses like
98 * "WHERE q.type IN ($QTYPE_MANUAL)".
99 */
100$QTYPE_MANUAL = '';
101/**
102 * String in the format "'type1','type2'" that can be used in SQL clauses like
103 * "WHERE q.type NOT IN ($QTYPE_EXCLUDE_FROM_RANDOM)".
104 */
105$QTYPE_EXCLUDE_FROM_RANDOM = '';
106
107/**
108 * Add a new question type to the various global arrays above.
109 *
110 * @param object $qtype An instance of the new question type class.
111 */
112function question_register_questiontype($qtype) {
113 global $QTYPES, $QTYPE_MENU, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
114
115 $name = $qtype->name();
116 $QTYPES[$name] = $qtype;
117 $menuname = $qtype->menu_name();
118 if ($menuname) {
119 $QTYPE_MENU[$name] = $menuname;
120 }
121 if ($qtype->is_manual_graded()) {
122 if ($QTYPE_MANUAL) {
123 $QTYPE_MANUAL .= ',';
124 }
125 $QTYPE_MANUAL .= "'$name'";
126 }
127 if (!$qtype->is_usable_by_random()) {
128 if ($QTYPE_EXCLUDE_FROM_RANDOM) {
129 $QTYPE_EXCLUDE_FROM_RANDOM .= ',';
130 }
131 $QTYPE_EXCLUDE_FROM_RANDOM .= "'$name'";
132 }
133}
516cf3eb 134
643ec47d 135require_once("$CFG->dirroot/question/type/questiontype.php");
516cf3eb 136
a2156789 137// Load the questiontype.php file for each question type
138// These files in turn call question_register_questiontype()
139// with a new instance of each qtype class.
aaae75b0 140$qtypenames= get_list_of_plugins('question/type');
f02c6f01 141foreach($qtypenames as $qtypename) {
142 // Instanciates all plug-in question types
643ec47d 143 $qtypefilepath= "$CFG->dirroot/question/type/$qtypename/questiontype.php";
f02c6f01 144
145 // echo "Loading $qtypename<br/>"; // Uncomment for debugging
146 if (is_readable($qtypefilepath)) {
147 require_once($qtypefilepath);
516cf3eb 148 }
149}
150
151/// OTHER CLASSES /////////////////////////////////////////////////////////
152
153/**
6b11a0e8 154 * This holds the options that are set by the course module
155 */
516cf3eb 156class cmoptions {
157 /**
158 * Whether a new attempt should be based on the previous one. If true
159 * then a new attempt will start in a state where all responses are set
160 * to the last responses from the previous attempt.
161 */
162 var $attemptonlast = false;
163
164 /**
165 * Various option flags. The flags are accessed via bitwise operations
166 * using the constants defined in the CONSTANTS section above.
167 */
6b11a0e8 168 var $optionflags = QUESTION_ADAPTIVE;
516cf3eb 169
170 /**
171 * Determines whether in the calculation of the score for a question
172 * penalties for earlier wrong responses within the same attempt will
173 * be subtracted.
174 */
175 var $penaltyscheme = true;
176
177 /**
178 * The maximum time the user is allowed to answer the questions withing
179 * an attempt. This is measured in minutes so needs to be multiplied by
180 * 60 before compared to timestamps. If set to 0 no timelimit will be applied
181 */
182 var $timelimit = 0;
183
184 /**
185 * Timestamp for the closing time. Responses submitted after this time will
186 * be saved but no credit will be given for them.
187 */
188 var $timeclose = 9999999999;
189
190 /**
191 * The id of the course from withing which the question is currently being used
192 */
193 var $course = SITEID;
194
195 /**
196 * Whether the answers in a multiple choice question should be randomly
197 * shuffled when a new attempt is started.
198 */
c4c11af8 199 var $shuffleanswers = true;
516cf3eb 200
201 /**
202 * The number of decimals to be shown when scores are printed
203 */
204 var $decimalpoints = 2;
516cf3eb 205}
206
207
516cf3eb 208/// FUNCTIONS //////////////////////////////////////////////////////
209
90c3f310 210/**
f67172b6 211 * Returns an array of names of activity modules that use this question
90c3f310 212 *
f67172b6 213 * @param object $questionid
214 * @return array of strings
90c3f310 215 */
f67172b6 216function question_list_instances($questionid) {
90c3f310 217 $instances = array();
218 $modules = get_records('modules');
219 foreach ($modules as $module) {
f67172b6 220 $fn = $module->name.'_question_list_instances';
90c3f310 221 if (function_exists($fn)) {
f67172b6 222 $instances = $instances + $fn($questionid);
90c3f310 223 }
224 }
225 return $instances;
226}
516cf3eb 227
e9de4366 228
229/**
230 * Returns list of 'allowed' grades for grade selection
231 * formatted suitably for dropdown box function
232 * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
233 */
234function get_grade_options() {
235 // define basic array of grades
236 $grades = array(
237 1,
238 0.9,
239 0.8,
240 0.75,
241 0.70,
242 0.66666,
243 0.60,
244 0.50,
245 0.40,
246 0.33333,
247 0.30,
248 0.25,
249 0.20,
250 0.16666,
251 0.142857,
252 0.125,
253 0.11111,
254 0.10,
255 0.05,
256 0);
257
258 // iterate through grades generating full range of options
259 $gradeoptionsfull = array();
260 $gradeoptions = array();
261 foreach ($grades as $grade) {
262 $percentage = 100 * $grade;
263 $neggrade = -$grade;
264 $gradeoptions["$grade"] = "$percentage %";
265 $gradeoptionsfull["$grade"] = "$percentage %";
266 $gradeoptionsfull["$neggrade"] = -$percentage." %";
267 }
268 $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
269
270 // sort lists
271 arsort($gradeoptions, SORT_NUMERIC);
272 arsort($gradeoptionsfull, SORT_NUMERIC);
273
274 // construct return object
275 $grades = new stdClass;
276 $grades->gradeoptions = $gradeoptions;
277 $grades->gradeoptionsfull = $gradeoptionsfull;
278
279 return $grades;
280}
281
8511669c 282/**
283 * match grade options
284 * if no match return error or match nearest
285 * @param array $gradeoptionsfull list of valid options
286 * @param int $grade grade to be tested
287 * @param string $matchgrades 'error' or 'nearest'
288 * @return mixed either 'fixed' value or false if erro
289 */
290function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
291 // if we just need an error...
292 if ($matchgrades=='error') {
293 foreach($gradeoptionsfull as $value => $option) {
2784b982 294 // slightly fuzzy test, never check floats for equality :-)
295 if (abs($grade-$value)<0.00001) {
8511669c 296 return $grade;
297 }
298 }
299 // didn't find a match so that's an error
300 return false;
301 }
302 // work out nearest value
303 else if ($matchgrades=='nearest') {
304 $hownear = array();
305 foreach($gradeoptionsfull as $value => $option) {
306 if ($grade==$value) {
307 return $grade;
308 }
309 $hownear[ $value ] = abs( $grade - $value );
310 }
311 // reverse sort list of deltas and grab the last (smallest)
312 asort( $hownear, SORT_NUMERIC );
313 reset( $hownear );
314 return key( $hownear );
315 }
316 else {
317 return false;
318 }
319}
320
f67172b6 321/**
322 * Tests whether a category is in use by any activity module
323 *
324 * @return boolean
325 * @param integer $categoryid
326 * @param boolean $recursive Whether to examine category children recursively
327 */
328function question_category_isused($categoryid, $recursive = false) {
329
330 //Look at each question in the category
331 if ($questions = get_records('question', 'category', $categoryid)) {
332 foreach ($questions as $question) {
333 if (count(question_list_instances($question->id))) {
334 return true;
335 }
336 }
337 }
338
339 //Look under child categories recursively
340 if ($recursive) {
341 if ($children = get_records('question_categories', 'parent', $categoryid)) {
342 foreach ($children as $child) {
343 if (question_category_isused($child->id, $recursive)) {
344 return true;
345 }
346 }
347 }
348 }
349
350 return false;
351}
352
0429cd86 353/**
354 * Deletes all data associated to an attempt from the database
355 *
93eb0ea3 356 * @param integer $attemptid The id of the attempt being deleted
0429cd86 357 */
358function delete_attempt($attemptid) {
359 global $QTYPES;
360
361 $states = get_records('question_states', 'attempt', $attemptid);
362 $stateslist = implode(',', array_keys($states));
363
364 // delete questiontype-specific data
365 foreach ($QTYPES as $qtype) {
366 $qtype->delete_states($stateslist);
367 }
368
369 // delete entries from all other question tables
370 // It is important that this is done only after calling the questiontype functions
371 delete_records("question_states", "attempt", $attemptid);
372 delete_records("question_sessions", "attemptid", $attemptid);
36be25f6 373 delete_records("question_attempts", "id", $attemptid);
0429cd86 374
375 return;
376}
f67172b6 377
516cf3eb 378/**
6b11a0e8 379 * Deletes question and all associated data from the database
380 *
90c3f310 381 * It will not delete a question if it is used by an activity module
6b11a0e8 382 * @param object $question The question being deleted
383 */
90c3f310 384function delete_question($questionid) {
f02c6f01 385 global $QTYPES;
90c3f310 386
387 // Do not delete a question if it is used by an activity module
f67172b6 388 if (count(question_list_instances($questionid))) {
90c3f310 389 return;
390 }
391
392 // delete questiontype-specific data
cf60a28a 393 if ($question = get_record('question', 'id', $questionid)) {
394 if (isset($QTYPES[$question->qtype])) {
395 $QTYPES[$question->qtype]->delete_question($questionid);
396 }
397 } else {
398 echo "Question with id $questionid does not exist.<br />";
516cf3eb 399 }
90c3f310 400
5cb9076a 401 if ($states = get_records('question_states', 'question', $questionid)) {
402 $stateslist = implode(',', array_keys($states));
403
404 // delete questiontype-specific data
405 foreach ($QTYPES as $qtype) {
406 $qtype->delete_states($stateslist);
407 }
0429cd86 408 }
409
90c3f310 410 // delete entries from all other question tables
411 // It is important that this is done only after calling the questiontype functions
412 delete_records("question_answers", "question", $questionid);
413 delete_records("question_states", "question", $questionid);
414 delete_records("question_sessions", "questionid", $questionid);
415
416 // Now recursively delete all child questions
417 if ($children = get_records('question', 'parent', $questionid)) {
516cf3eb 418 foreach ($children as $child) {
1d723a16 419 if ($child->id != $questionid) {
420 delete_question($child->id);
421 }
516cf3eb 422 }
423 }
90c3f310 424
425 // Finally delete the question record itself
426 delete_records('question', 'id', $questionid);
427
428 return;
516cf3eb 429}
430
f67172b6 431/**
432 * All non-used question categories and their questions are deleted and
433 * categories still used by other courses are moved to the site course.
434 *
435 * @param object $course an object representing the course
436 * @param boolean $feedback to specify if the process must output a summary of its work
437 * @return boolean
438 */
439function question_delete_course($course, $feedback=true) {
440
441 global $CFG, $QTYPES;
442
443 //To detect if we have created the "container category"
444 $concatid = 0;
445
446 //The "container" category we'll create if we need if
447 $contcat = new object;
448
449 //To temporary store changes performed with parents
450 $parentchanged = array();
451
452 //To store feedback to be showed at the end of the process
453 $feedbackdata = array();
454
455 //Cache some strings
456 $strcatcontainer=get_string('containercategorycreated', 'quiz');
457 $strcatmoved = get_string('usedcategorymoved', 'quiz');
458 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
459
460 if ($categories = get_records('question_categories', 'course', $course->id, 'parent', 'id, parent, name, course')) {
461
462 //Sort categories following their tree (parent-child) relationships
463 $categories = sort_categories_by_tree($categories);
464
465 foreach ($categories as $cat) {
466
467 //Get the full record
468 $category = get_record('question_categories', 'id', $cat->id);
469
470 //Check if the category is being used anywhere
471 if(question_category_isused($category->id, true)) {
472 //It's being used. Cannot delete it, so:
473 //Create a container category in SITEID course if it doesn't exist
474 if (!$concatid) {
d7444d44 475 $concat = new stdClass;
f67172b6 476 $concat->course = SITEID;
477 if (!isset($course->shortname)) {
478 $course->shortname = 'id=' . $course->id;
479 }
480 $concat->name = get_string('savedfromdeletedcourse', 'quiz', $course->shortname);
481 $concat->info = $concat->name;
482 $concat->publish = 1;
483 $concat->stamp = make_unique_id_code();
484 $concatid = insert_record('question_categories', $concat);
485
486 //Fill feedback
487 $feedbackdata[] = array($concat->name, $strcatcontainer);
488 }
489 //Move the category to the container category in SITEID course
490 $category->course = SITEID;
491 //Assign to container if the category hasn't parent or if the parent is wrong (not belongs to the course)
492 if (!$category->parent || !isset($categories[$category->parent])) {
493 $category->parent = $concatid;
494 }
495 //If it's being used, its publish field should be 1
496 $category->publish = 1;
497 //Let's update it
498 update_record('question_categories', $category);
499
500 //Save this parent change for future use
501 $parentchanged[$category->id] = $category->parent;
502
503 //Fill feedback
504 $feedbackdata[] = array($category->name, $strcatmoved);
505
506 } else {
507 //Category isn't being used so:
508 //Delete it completely (questions and category itself)
509 //deleting questions
510 if ($questions = get_records("question", "category", $category->id)) {
511 foreach ($questions as $question) {
512 delete_question($question->id);
513 }
514 delete_records("question", "category", $category->id);
515 }
516 //delete the category
517 delete_records('question_categories', 'id', $category->id);
518
519 //Save this parent change for future use
520 if (!empty($category->parent)) {
521 $parentchanged[$category->id] = $category->parent;
522 } else {
523 $parentchanged[$category->id] = $concatid;
524 }
525
526 //Update all its child categories to re-parent them to grandparent.
527 set_field ('question_categories', 'parent', $parentchanged[$category->id], 'parent', $category->id);
528
529 //Fill feedback
530 $feedbackdata[] = array($category->name, $strcatdeleted);
531 }
532 }
533 //Inform about changes performed if feedback is enabled
534 if ($feedback) {
d7444d44 535 $table = new stdClass;
f67172b6 536 $table->head = array(get_string('category','quiz'), get_string('action'));
537 $table->data = $feedbackdata;
538 print_table($table);
539 }
540 }
541 return true;
542}
543
516cf3eb 544/**
d7444d44 545 * Private function to factor common code out of get_question_options().
546 *
547 * @param object $question the question to tidy.
548 * @return boolean true if successful, else false.
549 */
550function _tidy_question(&$question) {
f02c6f01 551 global $QTYPES;
d7444d44 552 if (!array_key_exists($question->qtype, $QTYPES)) {
553 $question->qtype = 'missingtype';
554 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
555 }
556 $question->name_prefix = question_make_name_prefix($question->id);
557 return $QTYPES[$question->qtype]->get_question_options($question);
558}
516cf3eb 559
d7444d44 560/**
561 * Updates the question objects with question type specific
562 * information by calling {@link get_question_options()}
563 *
564 * Can be called either with an array of question objects or with a single
565 * question object.
566 *
567 * @param mixed $questions Either an array of question objects to be updated
568 * or just a single question object
569 * @return bool Indicates success or failure.
570 */
571function get_question_options(&$questions) {
516cf3eb 572 if (is_array($questions)) { // deal with an array of questions
d7444d44 573 foreach ($questions as $i => $notused) {
574 if (!_tidy_question($questions[$i])) {
516cf3eb 575 return false;
d7444d44 576 }
516cf3eb 577 }
578 return true;
579 } else { // deal with single question
d7444d44 580 return _tidy_question($questions);
516cf3eb 581 }
582}
583
584/**
585* Loads the most recent state of each question session from the database
586* or create new one.
587*
588* For each question the most recent session state for the current attempt
4f48fb42 589* is loaded from the question_states table and the question type specific data and
590* responses are added by calling {@link restore_question_state()} which in turn
516cf3eb 591* calls {@link restore_session_and_responses()} for each question.
592* If no states exist for the question instance an empty state object is
593* created representing the start of a session and empty question
594* type specific information and responses are created by calling
595* {@link create_session_and_responses()}.
516cf3eb 596*
597* @return array An array of state objects representing the most recent
598* states of the question sessions.
599* @param array $questions The questions for which sessions are to be restored or
600* created.
601* @param object $cmoptions
602* @param object $attempt The attempt for which the question sessions are
603* to be restored or created.
604*/
4f48fb42 605function get_question_states(&$questions, $cmoptions, $attempt) {
f02c6f01 606 global $CFG, $QTYPES;
516cf3eb 607
608 // get the question ids
609 $ids = array_keys($questions);
610 $questionlist = implode(',', $ids);
611
612 // The question field must be listed first so that it is used as the
613 // array index in the array returned by get_records_sql
3e3e5a40 614 $statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment';
516cf3eb 615 // Load the newest states for the questions
616 $sql = "SELECT $statefields".
4f48fb42 617 " FROM {$CFG->prefix}question_states s,".
516cf3eb 618 " {$CFG->prefix}question_sessions n".
619 " WHERE s.id = n.newest".
620 " AND n.attemptid = '$attempt->uniqueid'".
621 " AND n.questionid IN ($questionlist)";
622 $states = get_records_sql($sql);
623
624 // Load the newest graded states for the questions
625 $sql = "SELECT $statefields".
4f48fb42 626 " FROM {$CFG->prefix}question_states s,".
516cf3eb 627 " {$CFG->prefix}question_sessions n".
628 " WHERE s.id = n.newgraded".
629 " AND n.attemptid = '$attempt->uniqueid'".
630 " AND n.questionid IN ($questionlist)";
631 $gradedstates = get_records_sql($sql);
632
633 // loop through all questions and set the last_graded states
634 foreach ($ids as $i) {
635 if (isset($states[$i])) {
4f48fb42 636 restore_question_state($questions[$i], $states[$i]);
516cf3eb 637 if (isset($gradedstates[$i])) {
4f48fb42 638 restore_question_state($questions[$i], $gradedstates[$i]);
516cf3eb 639 $states[$i]->last_graded = $gradedstates[$i];
640 } else {
641 $states[$i]->last_graded = clone($states[$i]);
516cf3eb 642 }
643 } else {
d23e3e11 644 // create a new empty state
645 $states[$i] = new object;
646 $states[$i]->attempt = $attempt->uniqueid;
647 $states[$i]->question = (int) $i;
648 $states[$i]->seq_number = 0;
649 $states[$i]->timestamp = $attempt->timestart;
650 $states[$i]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
651 $states[$i]->grade = 0;
652 $states[$i]->raw_grade = 0;
653 $states[$i]->penalty = 0;
654 $states[$i]->sumpenalty = 0;
3e3e5a40 655 $states[$i]->manualcomment = '';
d23e3e11 656 $states[$i]->responses = array('' => '');
657 // Prevent further changes to the session from incrementing the
658 // sequence number
659 $states[$i]->changed = true;
660
661 // Create the empty question type specific information
5a14d563 662 if (!$QTYPES[$questions[$i]->qtype]->create_session_and_responses(
663 $questions[$i], $states[$i], $cmoptions, $attempt)) {
d23e3e11 664 return false;
516cf3eb 665 }
d23e3e11 666 $states[$i]->last_graded = clone($states[$i]);
516cf3eb 667 }
668 }
669 return $states;
670}
671
672
673/**
674* Creates the run-time fields for the states
675*
676* Extends the state objects for a question by calling
677* {@link restore_session_and_responses()}
516cf3eb 678* @param object $question The question for which the state is needed
865b7534 679* @param object $state The state as loaded from the database
680* @return boolean Represents success or failure
516cf3eb 681*/
4f48fb42 682function restore_question_state(&$question, &$state) {
f02c6f01 683 global $QTYPES;
516cf3eb 684
685 // initialise response to the value in the answer field
865b7534 686 $state->responses = array('' => addslashes($state->answer));
516cf3eb 687 unset($state->answer);
3e3e5a40 688 $state->manualcomment = isset($state->manualcomment) ? addslashes($state->manualcomment) : '';
516cf3eb 689
690 // Set the changed field to false; any code which changes the
691 // question session must set this to true and must increment
4f48fb42 692 // ->seq_number. The save_question_session
516cf3eb 693 // function will save the new state object to the database if the field is
694 // set to true.
695 $state->changed = false;
696
697 // Load the question type specific data
f02c6f01 698 return $QTYPES[$question->qtype]
865b7534 699 ->restore_session_and_responses($question, $state);
516cf3eb 700
701}
702
703/**
704* Saves the current state of the question session to the database
705*
706* The state object representing the current state of the session for the
4f48fb42 707* question is saved to the question_states table with ->responses[''] saved
516cf3eb 708* to the answer field of the database table. The information in the
709* question_sessions table is updated.
710* The question type specific data is then saved.
bc2feba3 711* @return mixed The id of the saved or updated state or false
516cf3eb 712* @param object $question The question for which session is to be saved.
713* @param object $state The state information to be saved. In particular the
714* most recent responses are in ->responses. The object
715* is updated to hold the new ->id.
716*/
4f48fb42 717function save_question_session(&$question, &$state) {
f02c6f01 718 global $QTYPES;
516cf3eb 719 // Check if the state has changed
720 if (!$state->changed && isset($state->id)) {
bc2feba3 721 return $state->id;
516cf3eb 722 }
723 // Set the legacy answer field
724 $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
725
726 // Save the state
36be25f6 727 if (!empty($state->update)) { // this forces the old state record to be overwritten
4f48fb42 728 update_record('question_states', $state);
516cf3eb 729 } else {
4f48fb42 730 if (!$state->id = insert_record('question_states', $state)) {
516cf3eb 731 unset($state->id);
732 unset($state->answer);
733 return false;
734 }
b6e907a2 735 }
516cf3eb 736
b6e907a2 737 // create or update the session
2e9b6d15 738 if (!$session = get_record('question_sessions', 'attemptid',
865b7534 739 $state->attempt, 'questionid', $question->id)) {
2e9b6d15 740 $session->attemptid = $state->attempt;
741 $session->questionid = $question->id;
742 $session->newest = $state->id;
743 // The following may seem weird, but the newgraded field needs to be set
744 // already even if there is no graded state yet.
745 $session->newgraded = $state->id;
746 $session->sumpenalty = $state->sumpenalty;
3e3e5a40 747 $session->manualcomment = $state->manualcomment;
2e9b6d15 748 if (!insert_record('question_sessions', $session)) {
b6e907a2 749 error('Could not insert entry in question_sessions');
516cf3eb 750 }
b6e907a2 751 } else {
2e9b6d15 752 $session->newest = $state->id;
753 if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
754 // this state is graded or newly opened, so it goes into the lastgraded field as well
755 $session->newgraded = $state->id;
756 $session->sumpenalty = $state->sumpenalty;
3e3e5a40 757 $session->manualcomment = $state->manualcomment;
ef822868 758 } else {
3e3e5a40 759 $session->manualcomment = addslashes($session->manualcomment);
516cf3eb 760 }
2e9b6d15 761 update_record('question_sessions', $session);
516cf3eb 762 }
763
764 unset($state->answer);
765
766 // Save the question type specific state information and responses
f02c6f01 767 if (!$QTYPES[$question->qtype]->save_session_and_responses(
516cf3eb 768 $question, $state)) {
769 return false;
770 }
771 // Reset the changed flag
772 $state->changed = false;
bc2feba3 773 return $state->id;
516cf3eb 774}
775
776/**
777* Determines whether a state has been graded by looking at the event field
778*
779* @return boolean true if the state has been graded
780* @param object $state
781*/
4f48fb42 782function question_state_is_graded($state) {
bc2feba3 783 return ($state->event == QUESTION_EVENTGRADE
784 or $state->event == QUESTION_EVENTCLOSEANDGRADE
785 or $state->event == QUESTION_EVENTMANUALGRADE);
f30bbcaf 786}
787
788/**
789* Determines whether a state has been closed by looking at the event field
790*
791* @return boolean true if the state has been closed
792* @param object $state
793*/
794function question_state_is_closed($state) {
b6e907a2 795 return ($state->event == QUESTION_EVENTCLOSE
796 or $state->event == QUESTION_EVENTCLOSEANDGRADE
797 or $state->event == QUESTION_EVENTMANUALGRADE);
516cf3eb 798}
799
800
801/**
4dca7e51 802 * Extracts responses from submitted form
803 *
804 * This can extract the responses given to one or several questions present on a page
805 * It returns an array with one entry for each question, indexed by question id
806 * Each entry is an object with the properties
807 * ->event The event that has triggered the submission. This is determined by which button
808 * the user has pressed.
809 * ->responses An array holding the responses to an individual question, indexed by the
810 * name of the corresponding form element.
811 * ->timestamp A unix timestamp
812 * @return array array of action objects, indexed by question ids.
813 * @param array $questions an array containing at least all questions that are used on the form
814 * @param array $formdata the data submitted by the form on the question page
815 * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted
816 */
817function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
516cf3eb 818
4dca7e51 819 $time = time();
516cf3eb 820 $actions = array();
4dca7e51 821 foreach ($formdata as $key => $response) {
516cf3eb 822 // Get the question id from the response name
4f48fb42 823 if (false !== ($quid = question_get_id_from_name_prefix($key))) {
516cf3eb 824 // check if this is a valid id
825 if (!isset($questions[$quid])) {
826 error('Form contained question that is not in questionids');
827 }
828
829 // Remove the name prefix from the name
830 //decrypt trying
831 $key = substr($key, strlen($questions[$quid]->name_prefix));
832 if (false === $key) {
833 $key = '';
834 }
835 // Check for question validate and mark buttons & set events
836 if ($key === 'validate') {
4f48fb42 837 $actions[$quid]->event = QUESTION_EVENTVALIDATE;
4dca7e51 838 } else if ($key === 'submit') {
f30bbcaf 839 $actions[$quid]->event = QUESTION_EVENTSUBMIT;
516cf3eb 840 } else {
841 $actions[$quid]->event = $defaultevent;
842 }
843
844 // Update the state with the new response
845 $actions[$quid]->responses[$key] = $response;
4dca7e51 846
847 // Set the timestamp
848 $actions[$quid]->timestamp = $time;
516cf3eb 849 }
850 }
3ebdddf7 851 foreach ($actions as $quid => $notused) {
852 ksort($actions[$quid]->responses);
853 }
516cf3eb 854 return $actions;
855}
856
857
858
859/**
860* For a given question in an attempt we walk the complete history of states
861* and recalculate the grades as we go along.
862*
863* This is used when a question is changed and old student
864* responses need to be marked with the new version of a question.
865*
4f48fb42 866* TODO: Make sure this is not quiz-specific
867*
0a5b58af 868* @return boolean Indicates whether the grade has changed
516cf3eb 869* @param object $question A question object
870* @param object $attempt The attempt, in which the question needs to be regraded.
871* @param object $cmoptions
872* @param boolean $verbose Optional. Whether to print progress information or not.
873*/
4f48fb42 874function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false) {
516cf3eb 875
876 // load all states for this question in this attempt, ordered in sequence
4f48fb42 877 if ($states = get_records_select('question_states',
865b7534 878 "attempt = '{$attempt->uniqueid}' AND question = '{$question->id}'",
879 'seq_number ASC')) {
516cf3eb 880 $states = array_values($states);
881
882 // Subtract the grade for the latest state from $attempt->sumgrades to get the
883 // sumgrades for the attempt without this question.
884 $attempt->sumgrades -= $states[count($states)-1]->grade;
885
886 // Initialise the replaystate
887 $state = clone($states[0]);
3e3e5a40 888 $state->manualcomment = get_field('question_sessions', 'manualcomment', 'attemptid',
82b5d7cd 889 $attempt->uniqueid, 'questionid', $question->id);
4f48fb42 890 restore_question_state($question, $state);
516cf3eb 891 $state->sumpenalty = 0.0;
892 $replaystate = clone($state);
893 $replaystate->last_graded = $state;
894
0a5b58af 895 $changed = false;
516cf3eb 896 for($j = 1; $j < count($states); $j++) {
4f48fb42 897 restore_question_state($question, $states[$j]);
516cf3eb 898 $action = new stdClass;
899 $action->responses = $states[$j]->responses;
900 $action->timestamp = $states[$j]->timestamp;
901
f30bbcaf 902 // Change event to submit so that it will be reprocessed
0a5b58af 903 if (QUESTION_EVENTCLOSE == $states[$j]->event
865b7534 904 or QUESTION_EVENTGRADE == $states[$j]->event
905 or QUESTION_EVENTCLOSEANDGRADE == $states[$j]->event) {
f30bbcaf 906 $action->event = QUESTION_EVENTSUBMIT;
516cf3eb 907
908 // By default take the event that was saved in the database
909 } else {
910 $action->event = $states[$j]->event;
911 }
36be25f6 912
5e60643e 913 if ($action->event == QUESTION_EVENTMANUALGRADE) {
865b7534 914 question_process_comment($question, $replaystate, $attempt,
3e3e5a40 915 $replaystate->manualcomment, $states[$j]->grade);
36be25f6 916 } else {
917
918 // Reprocess (regrade) responses
865b7534 919 if (!question_process_responses($question, $replaystate,
920 $action, $cmoptions, $attempt)) {
36be25f6 921 $verbose && notify("Couldn't regrade state #{$state->id}!");
922 }
516cf3eb 923 }
924
925 // We need rounding here because grades in the DB get truncated
926 // e.g. 0.33333 != 0.3333333, but we want them to be equal here
0a5b58af 927 if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
865b7534 928 or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
929 or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
0a5b58af 930 $changed = true;
516cf3eb 931 }
932
933 $replaystate->id = $states[$j]->id;
36be25f6 934 $replaystate->changed = true;
d23e3e11 935 $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
4f48fb42 936 save_question_session($question, $replaystate);
516cf3eb 937 }
0a5b58af 938 if ($changed) {
1780811d 939 // TODO, call a method in quiz to do this, where 'quiz' comes from
940 // the question_attempts table.
0a5b58af 941 update_record('quiz_attempts', $attempt);
516cf3eb 942 }
943
0a5b58af 944 return $changed;
516cf3eb 945 }
0a5b58af 946 return false;
516cf3eb 947}
948
949/**
950* Processes an array of student responses, grading and saving them as appropriate
951*
952* @return boolean Indicates success/failure
953* @param object $question Full question object, passed by reference
954* @param object $state Full state object, passed by reference
955* @param object $action object with the fields ->responses which
956* is an array holding the student responses,
4f48fb42 957* ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
516cf3eb 958* and ->timestamp which is a timestamp from when the responses
959* were submitted by the student.
960* @param object $cmoptions
961* @param object $attempt The attempt is passed by reference so that
962* during grading its ->sumgrades field can be updated
963*/
4f48fb42 964function question_process_responses(&$question, &$state, $action, $cmoptions, &$attempt) {
f02c6f01 965 global $QTYPES;
516cf3eb 966
967 // if no responses are set initialise to empty response
968 if (!isset($action->responses)) {
969 $action->responses = array('' => '');
970 }
971
972 // make sure these are gone!
755bddf1 973 unset($action->responses['submit'], $action->responses['validate']);
516cf3eb 974
975 // Check the question session is still open
f30bbcaf 976 if (question_state_is_closed($state)) {
516cf3eb 977 return true;
978 }
f30bbcaf 979
516cf3eb 980 // If $action->event is not set that implies saving
981 if (! isset($action->event)) {
4f48fb42 982 $action->event = QUESTION_EVENTSAVE;
516cf3eb 983 }
f30bbcaf 984 // If submitted then compare against last graded
516cf3eb 985 // responses, not last given responses in this case
4f48fb42 986 if (question_isgradingevent($action->event)) {
516cf3eb 987 $state->responses = $state->last_graded->responses;
988 }
5a14d563 989
516cf3eb 990 // Check for unchanged responses (exactly unchanged, not equivalent).
991 // We also have to catch questions that the student has not yet attempted
5a14d563 992 $sameresponses = !$state->last_graded->event == QUESTION_EVENTOPEN &&
c82f76d0 993 $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
516cf3eb 994
f30bbcaf 995 // If the response has not been changed then we do not have to process it again
996 // unless the attempt is closing or validation is requested
4f48fb42 997 if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
5a14d563 998 and QUESTION_EVENTVALIDATE != $action->event) {
516cf3eb 999 return true;
1000 }
1001
1002 // Roll back grading information to last graded state and set the new
1003 // responses
1004 $newstate = clone($state->last_graded);
1005 $newstate->responses = $action->responses;
1006 $newstate->seq_number = $state->seq_number + 1;
1007 $newstate->changed = true; // will assure that it gets saved to the database
ca56222d 1008 $newstate->last_graded = clone($state->last_graded);
516cf3eb 1009 $newstate->timestamp = $action->timestamp;
1010 $state = $newstate;
1011
1012 // Set the event to the action we will perform. The question type specific
4f48fb42 1013 // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
516cf3eb 1014 // attempt at the question causes the session to close
1015 $state->event = $action->event;
1016
4f48fb42 1017 if (!question_isgradingevent($action->event)) {
516cf3eb 1018 // Grade the response but don't update the overall grade
f02c6f01 1019 $QTYPES[$question->qtype]->grade_responses(
516cf3eb 1020 $question, $state, $cmoptions);
f30bbcaf 1021 // Don't allow the processing to change the event type
516cf3eb 1022 $state->event = $action->event;
1023
ca56222d 1024 } else { // grading event
516cf3eb 1025
ca56222d 1026 // Unless the attempt is closing, we want to work out if the current responses
1027 // (or equivalent responses) were already given in the last graded attempt.
5a14d563 1028 if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
1029 $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
f30bbcaf 1030 $state->event = QUESTION_EVENTDUPLICATE;
516cf3eb 1031 }
f30bbcaf 1032
ca56222d 1033 // If we did not find a duplicate or if the attempt is closing, perform grading
5a14d563 1034 if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
1035 QUESTION_EVENTCLOSE == $action->event) {
f30bbcaf 1036 // Decrease sumgrades by previous grade and then later add new grade
1037 $attempt->sumgrades -= (float)$state->last_graded->grade;
1038
1039 $QTYPES[$question->qtype]->grade_responses(
1040 $question, $state, $cmoptions);
1041 // Calculate overall grade using correct penalty method
1042 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
516cf3eb 1043
1d7e0934 1044 $attempt->sumgrades += (float)$state->grade;
516cf3eb 1045 }
516cf3eb 1046
ca56222d 1047 // If the state was graded we need to update the last_graded field.
1048 if (question_state_is_graded($state)) {
1049 unset($state->last_graded);
1050 $state->last_graded = clone($state);
1051 unset($state->last_graded->changed);
1052 }
516cf3eb 1053 }
1054 $attempt->timemodified = $action->timestamp;
1055
1056 return true;
1057}
1058
1059/**
1060* Determine if event requires grading
1061*/
4f48fb42 1062function question_isgradingevent($event) {
f30bbcaf 1063 return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
516cf3eb 1064}
1065
516cf3eb 1066/**
1067* Applies the penalty from the previous graded responses to the raw grade
1068* for the current responses
1069*
1070* The grade for the question in the current state is computed by subtracting the
1071* penalty accumulated over the previous graded responses at the question from the
1072* raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
1073* the grade is set to zero. The ->grade field of the state object is modified to
1074* reflect the new grade but is never allowed to decrease.
1075* @param object $question The question for which the penalty is to be applied.
1076* @param object $state The state for which the grade is to be set from the
1077* raw grade and the cumulative penalty from the last
1078* graded state. The ->grade field is updated by applying
1079* the penalty scheme determined in $cmoptions to the ->raw_grade and
1080* ->last_graded->penalty fields.
1081* @param object $cmoptions The options set by the course module.
1082* The ->penaltyscheme field determines whether penalties
1083* for incorrect earlier responses are subtracted.
1084*/
4f48fb42 1085function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
f30bbcaf 1086 // deal with penalty
516cf3eb 1087 if ($cmoptions->penaltyscheme) {
1088 $state->grade = $state->raw_grade - $state->sumpenalty;
1089 $state->sumpenalty += (float) $state->penalty;
1090 } else {
1091 $state->grade = $state->raw_grade;
1092 }
1093
1094 // deal with timelimit
1095 if ($cmoptions->timelimit) {
1096 // We allow for 5% uncertainty in the following test
1097 if (($state->timestamp - $attempt->timestart) > ($cmoptions->timelimit * 63)) {
1098 $state->grade = 0;
1099 }
1100 }
1101
1102 // deal with closing time
1103 if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
1104 and !$attempt->preview) { // ignore closing time for previews
1105 $state->grade = 0;
1106 }
1107
1108 // Ensure that the grade does not go down
1109 $state->grade = max($state->grade, $state->last_graded->grade);
1110}
1111
516cf3eb 1112/**
1113* Print the icon for the question type
1114*
1115* @param object $question The question object for which the icon is required
1116* @param boolean $editlink If true then the icon is a link to the question
1117* edit page.
1118* @param boolean $return If true the functions returns the link as a string
1119*/
4f48fb42 1120function print_question_icon($question, $editlink=true, $return = false) {
516cf3eb 1121// returns a question icon
1122
dc1f00de 1123 global $QTYPES, $CFG;
516cf3eb 1124
ca252edb 1125 $namestr = get_string($question->qtype, 'quiz');
643ec47d 1126 $html = '<img border="0" height="16" width="16" src="'.$CFG->wwwroot.'/question/type/'.
4eda4eec 1127 $question->qtype.'/icon.gif" alt="'.
ca252edb 1128 $namestr.'" title="'.$namestr.'" />';
516cf3eb 1129
1130 if ($editlink) {
e56a08dc 1131 $html = "<a href=\"$CFG->wwwroot/question/question.php?id=$question->id\" title=\""
4eda4eec 1132 .$question->qtype."\">".
516cf3eb 1133 $html."</a>\n";
1134 }
1135 if ($return) {
1136 return $html;
1137 } else {
1138 echo $html;
1139 }
1140}
1141
516cf3eb 1142/**
1143* Returns a html link to the question image if there is one
1144*
1145* @return string The html image tag or the empy string if there is no image.
1146* @param object $question The question object
1147*/
4f48fb42 1148function get_question_image($question, $courseid) {
516cf3eb 1149
1150 global $CFG;
1151 $img = '';
1152
1153 if ($question->image) {
1154
1155 if (substr(strtolower($question->image), 0, 7) == 'http://') {
1156 $img .= $question->image;
1157
1158 } else if ($CFG->slasharguments) { // Use this method if possible for better caching
1159 $img .= "$CFG->wwwroot/file.php/$courseid/$question->image";
1160
1161 } else {
87075ec1 1162 $img .= "$CFG->wwwroot/file.php?file=/$courseid/$question->image";
516cf3eb 1163 }
1164 }
1165 return $img;
1166}
b6e907a2 1167
1168function question_print_comment_box($question, $state, $attempt, $url) {
2a2aba27 1169 global $CFG;
1170
848d886e 1171 $prefix = 'response';
2a2aba27 1172 $usehtmleditor = can_use_richtext_editor();
1173 $grade = round($state->last_graded->grade, 3);
1174 echo '<form method="post" action="'.$url.'">';
1175 include($CFG->dirroot.'/question/comment.html');
1176 echo '<input type="hidden" name="attempt" value="'.$attempt->uniqueid.'" />';
1177 echo '<input type="hidden" name="question" value="'.$question->id.'" />';
1178 echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
1179 echo '<input type="submit" name="submit" value="'.get_string('save', 'quiz').'" />';
1180 echo '</form>';
1181
1182 if ($usehtmleditor) {
848d886e 1183 use_html_editor();
2a2aba27 1184 }
b6e907a2 1185}
1186
1187function question_process_comment($question, &$state, &$attempt, $comment, $grade) {
1188
1189 // Update the comment and save it in the database
3e3e5a40 1190 $state->manualcomment = $comment;
13835c77 1191 if (!set_field('question_sessions', 'manualcomment', $comment, 'attemptid', $attempt->uniqueid, 'questionid', $question->id)) {
b6e907a2 1192 error("Cannot save comment");
1193 }
95ac8a40 1194
1195 // Update the attempt if the score has changed.
b6e907a2 1196 if (abs($state->last_graded->grade - $grade) > 0.002) {
b6e907a2 1197 $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
1198 $attempt->timemodified = time();
1199 if (!update_record('quiz_attempts', $attempt)) {
1200 error('Failed to save the current quiz attempt!');
1201 }
95ac8a40 1202 }
b6e907a2 1203
95ac8a40 1204 // Update the state if either the score has changed, or this is the first
1205 // manual grade event.
1206 // We don't need to store the modified state in the database, we just need
1207 // to set the $state->changed flag.
1208 if (abs($state->last_graded->grade - $grade) > 0.002 ||
1209 $state->last_graded->event != QUESTION_EVENTMANUALGRADE) {
84bf852c 1210
1211 // We want to update existing state (rather than creating new one) if it
1212 // was itself created by a manual grading event.
1213 $state->update = ($state->event == QUESTION_EVENTMANUALGRADE) ? 1 : 0;
1214
1215 // Update the other parts of the state object.
b6e907a2 1216 $state->raw_grade = $grade;
1217 $state->grade = $grade;
1218 $state->penalty = 0;
1219 $state->timestamp = time();
36be25f6 1220 $state->seq_number++;
95ac8a40 1221 $state->event = QUESTION_EVENTMANUALGRADE;
1222
b6e907a2 1223 // Update the last graded state (don't simplify!)
1224 unset($state->last_graded);
1225 $state->last_graded = clone($state);
95ac8a40 1226
1227 // We need to indicate that the state has changed in order for it to be saved.
1228 $state->changed = 1;
b6e907a2 1229 }
1230
1231}
1232
516cf3eb 1233/**
1234* Construct name prefixes for question form element names
1235*
1236* Construct the name prefix that should be used for example in the
1237* names of form elements created by questions.
4f48fb42 1238* This is called by {@link get_question_options()}
516cf3eb 1239* to set $question->name_prefix.
1240* This name prefix includes the question id which can be
4f48fb42 1241* extracted from it with {@link question_get_id_from_name_prefix()}.
516cf3eb 1242*
1243* @return string
1244* @param integer $id The question id
1245*/
4f48fb42 1246function question_make_name_prefix($id) {
516cf3eb 1247 return 'resp' . $id . '_';
1248}
1249
1250/**
1251* Extract question id from the prefix of form element names
1252*
1253* @return integer The question id
1254* @param string $name The name that contains a prefix that was
4f48fb42 1255* constructed with {@link question_make_name_prefix()}
516cf3eb 1256*/
4f48fb42 1257function question_get_id_from_name_prefix($name) {
516cf3eb 1258 if (!preg_match('/^resp([0-9]+)_/', $name, $matches))
1259 return false;
1260 return (integer) $matches[1];
1261}
1262
4f48fb42 1263/**
4dca7e51 1264 * Returns the unique id for a new attempt
1265 *
1266 * Every module can keep their own attempts table with their own sequential ids but
1267 * the question code needs to also have a unique id by which to identify all these
1268 * attempts. Hence a module, when creating a new attempt, calls this function and
1269 * stores the return value in the 'uniqueid' field of its attempts table.
4f48fb42 1270 */
36be25f6 1271function question_new_attempt_uniqueid($modulename='quiz') {
516cf3eb 1272 global $CFG;
d7444d44 1273 $attempt = new stdClass;
36be25f6 1274 $attempt->modulename = $modulename;
1275 if (!$id = insert_record('question_attempts', $attempt)) {
1276 error('Could not create new entry in question_attempts table');
1277 }
1278 return $id;
516cf3eb 1279}
1280
36be25f6 1281/**
1282 * Creates a stamp that uniquely identifies this version of the question
cbe20043 1283 *
1284 * In future we want this to use a hash of the question data to guarantee that
1285 * identical versions have the same version stamp.
1286 *
1287 * @param object $question
1288 * @return string A unique version stamp
1289 */
1290function question_hash($question) {
1291 return make_unique_id_code();
1292}
1293
516cf3eb 1294
1295/// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
1296
4dca7e51 1297 /**
1298 * Prints a question
1299 *
1300 * Simply calls the question type specific print_question() method.
1301 * @param object $question The question to be rendered.
1302 * @param object $state The state to render the question in.
1303 * @param integer $number The number for this question.
1304 * @param object $cmoptions The options specified by the course module
1305 * @param object $options An object specifying the rendering options.
1306 */
4f48fb42 1307function print_question(&$question, &$state, $number, $cmoptions, $options=null) {
f02c6f01 1308 global $QTYPES;
516cf3eb 1309
f02c6f01 1310 $QTYPES[$question->qtype]->print_question($question, $state, $number,
516cf3eb 1311 $cmoptions, $options);
1312}
f5565b69 1313/**
1314* Saves question options
1315*
1316* Simply calls the question type specific save_question_options() method.
1317*/
1318function save_question_options($question) {
1319 global $QTYPES;
1320
1321 $QTYPES[$question->qtype]->save_question_options($question);
1322}
516cf3eb 1323
1324/**
1325* Gets all teacher stored answers for a given question
1326*
1327* Simply calls the question type specific get_all_responses() method.
1328*/
1329// ULPGC ecastro
4f48fb42 1330function get_question_responses($question, $state) {
f02c6f01 1331 global $QTYPES;
1332 $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
516cf3eb 1333 return $r;
1334}
1335
1336
1337/**
dc1f00de 1338* Gets the response given by the user in a particular state
516cf3eb 1339*
1340* Simply calls the question type specific get_actual_response() method.
1341*/
1342// ULPGC ecastro
4f48fb42 1343function get_question_actual_response($question, $state) {
f02c6f01 1344 global $QTYPES;
516cf3eb 1345
f02c6f01 1346 $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
516cf3eb 1347 return $r;
1348}
1349
1350/**
dc1f00de 1351* TODO: document this
516cf3eb 1352*/
1353// ULPGc ecastro
4f48fb42 1354function get_question_fraction_grade($question, $state) {
f02c6f01 1355 global $QTYPES;
516cf3eb 1356
f02c6f01 1357 $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
516cf3eb 1358 return $r;
1359}
1360
1361
1362/// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
1363
6cb4910c 1364/**
1365 * returns the categories with their names ordered following parent-child relationships
1366 * finally it tries to return pending categories (those being orphaned, whose parent is
1367 * incorrect) to avoid missing any category from original array.
1368 */
1369function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
1370 $children = array();
1371 $keys = array_keys($categories);
1372
1373 foreach ($keys as $key) {
1374 if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
1375 $children[$key] = $categories[$key];
1376 $categories[$key]->processed = true;
1377 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1378 }
1379 }
1380 //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
1381 if ($level == 1) {
1382 foreach ($keys as $key) {
1383 //If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
1384 if (!isset($categories[$key]->processed) && !record_exists('question_categories', 'course', $categories[$key]->course, 'id', $categories[$key]->parent)) {
1385 $children[$key] = $categories[$key];
1386 $categories[$key]->processed = true;
1387 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1388 }
1389 }
1390 }
1391 return $children;
1392}
1393
1394/**
062a7522 1395 * Private method, only for the use of add_indented_names().
1396 *
1397 * Recursively adds an indentedname field to each category, starting with the category
1398 * with id $id, and dealing with that category and all its children, and
1399 * return a new array, with those categories in the right order.
1400 *
1401 * @param array $categories an array of categories which has had childids
1402 * fields added by flatten_category_tree(). Passed by reference for
1403 * performance only. It is not modfied.
1404 * @param int $id the category to start the indenting process from.
1405 * @param int $depth the indent depth. Used in recursive calls.
1406 * @return array a new array of categories, in the right order for the tree.
6cb4910c 1407 */
062a7522 1408function flatten_category_tree(&$categories, $id, $depth = 0) {
1409
1410 // Indent the name of this category.
1411 $newcategories = array();
1412 $newcategories[$id] = $categories[$id];
1413 $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
1414
1415 // Recursively indent the children.
1416 foreach ($categories[$id]->childids as $childid) {
1417 $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1);
6cb4910c 1418 }
062a7522 1419
1420 // Remove the childids array that were temporarily added.
1421 unset($newcategories[$id]->childids);
1422
1423 return $newcategories;
6cb4910c 1424}
1425
1426/**
062a7522 1427 * Format categories into an indented list reflecting the tree structure.
1428 *
1429 * @param array $categories An array of category objects, for example from the.
1430 * @return array The formatted list of categories.
6cb4910c 1431 */
062a7522 1432function add_indented_names($categories) {
6cb4910c 1433
062a7522 1434 // Add an array to each category to hold the child category ids. This array will be removed
1435 // again by flatten_category_tree(). It should not be used outside these two functions.
1436 foreach (array_keys($categories) as $id) {
1437 $categories[$id]->childids = array();
6cb4910c 1438 }
1439
062a7522 1440 // Build the tree structure, and record which categories are top-level.
1441 // We have to be careful, because the categories array may include published
1442 // categories from other courses, but not their parents.
1443 $toplevelcategoryids = array();
1444 foreach (array_keys($categories) as $id) {
1445 if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
1446 $categories[$categories[$id]->parent]->childids[] = $id;
1447 } else {
1448 $toplevelcategoryids[] = $id;
6cb4910c 1449 }
1450 }
1451
062a7522 1452 // Flatten the tree to and add the indents.
1453 $newcategories = array();
1454 foreach ($toplevelcategoryids as $id) {
1455 $newcategories = $newcategories + flatten_category_tree($categories, $id);
6cb4910c 1456 }
1457
062a7522 1458 return $newcategories;
6cb4910c 1459}
2dd6d66b 1460
516cf3eb 1461/**
062a7522 1462 * Output a select menu of question categories.
1463 *
1464 * Categories from this course and (optionally) published categories from other courses
1465 * are included. Optionally, only categories the current user may edit can be included.
1466 *
1467 * @param integer $courseid the id of the course to get the categories for.
1468 * @param integer $published if true, include publised categories from other courses.
1469 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
1470 * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
1471 */
1472function question_category_select_menu($courseid, $published = false, $only_editable = false, $selected = "") {
b20ea669 1473 global $CFG;
1474
516cf3eb 1475 // get sql fragment for published
1476 $publishsql="";
1477 if ($published) {
b20ea669 1478 $publishsql = " OR publish = 1";
516cf3eb 1479 }
1480
b20ea669 1481 $categories = get_records_sql("
1482 SELECT cat.*, c.shortname AS coursename
1483 FROM {$CFG->prefix}question_categories cat, {$CFG->prefix}course c
1484 WHERE c.id = cat.course AND (cat.course = $courseid $publishsql)
f9c92efe 1485 ORDER BY cat.parent, cat.sortorder, cat.name ASC");
516cf3eb 1486
1487 $categories = add_indented_names($categories);
1488
1489 echo "<select name=\"category\">\n";
1490 foreach ($categories as $category) {
1491 $cid = $category->id;
dc1f00de 1492 $cname = question_category_coursename($category, $courseid);
516cf3eb 1493 $seltxt = "";
1494 if ($cid==$selected) {
1495 $seltxt = "selected=\"selected\"";
1496 }
1da5a362 1497 if ((!$only_editable) || has_capability('moodle/question:managecategory', get_context_instance(CONTEXT_COURSE, $category->course))) {
516cf3eb 1498 echo " <option value=\"$cid\" $seltxt>$cname</option>\n";
1499 }
1500 }
1501 echo "</select>\n";
1502}
1503
b20ea669 1504/**
1505 * If the category is not from this course, and it is a published category,
1506 * then return the course category name with the course shortname appended in
1507 * brackets. Otherwise, just return the category name.
1508 */
dc1f00de 1509function question_category_coursename($category, $courseid = 0) {
516cf3eb 1510 $cname = (isset($category->indentedname)) ? $category->indentedname : $category->name;
1511 if ($category->course != $courseid && $category->publish) {
b20ea669 1512 if (!empty($category->coursename)) {
1513 $coursename = $category->coursename;
1514 } else {
1515 $coursename = get_field('course', 'shortname', 'id', $category->course);
516cf3eb 1516 }
b20ea669 1517 $cname .= " ($coursename)";
516cf3eb 1518 }
1519 return $cname;
1520}
1521
516cf3eb 1522/**
b20ea669 1523 * Returns a comma separated list of ids of the category and all subcategories
1524 */
dc1f00de 1525function question_categorylist($categoryid) {
516cf3eb 1526 // returns a comma separated list of ids of the category and all subcategories
1527 $categorylist = $categoryid;
dc1f00de 1528 if ($subcategories = get_records('question_categories', 'parent', $categoryid, 'sortorder ASC', 'id, id')) {
516cf3eb 1529 foreach ($subcategories as $subcategory) {
dc1f00de 1530 $categorylist .= ','. question_categorylist($subcategory->id);
516cf3eb 1531 }
1532 }
1533 return $categorylist;
1534}
1535
f5c15407 1536/**
1537 * find and/or create the category described by a delimited list
1538 * e.g. tom/dick/harry
1539 * @param string catpath delimited category path
1540 * @param string delimiter path delimiting character
1541 * @param int courseid course to search for categories
1542 * @return mixed category object or null if fails
1543 */
1544function create_category_path( $catpath, $delimiter='/', $courseid=0 ) {
1545 $catnames = explode( $delimiter, $catpath );
1546 $parent = 0;
1547 $category = null;
1548 foreach ($catnames as $catname) {
1549 if ($category = get_record( 'question_categories', 'name', $catname, 'course', $courseid, 'parent', $parent )) {
1550 $parent = $category->id;
1551 }
1552 else {
1553 // create the new category
1554 $category = new object;
1555 $category->course = $courseid;
1556 $category->name = $catname;
1557 $category->info = '';
1558 $category->publish = false;
1559 $category->parent = $parent;
1560 $category->sortorder = 999;
1561 $category->stamp = make_unique_id_code();
1562 if (!($id = insert_record( 'question_categories', $category ))) {
1563 error( "cannot create new category - $catname" );
1564 }
1565 $category->id = $id;
1566 $parent = $id;
1567 }
1568 }
1569 return $category;
1570}
1571
516cf3eb 1572
947217d7 1573//===========================
1574// Import/Export Functions
1575//===========================
1576
ff4b6492 1577/**
1578 * Get list of available import or export formats
1579 * @param string $type 'import' if import list, otherwise export list assumed
1580 * @return array sorted list of import/export formats available
1581**/
1582function get_import_export_formats( $type ) {
1583
1584 global $CFG;
dc1f00de 1585 $fileformats = get_list_of_plugins("question/format");
ff4b6492 1586
1587 $fileformatname=array();
947217d7 1588 require_once( "{$CFG->dirroot}/question/format.php" );
ff4b6492 1589 foreach ($fileformats as $key => $fileformat) {
1590 $format_file = $CFG->dirroot . "/question/format/$fileformat/format.php";
1591 if (file_exists( $format_file ) ) {
1592 require_once( $format_file );
1593 }
1594 else {
1595 continue;
1596 }
70c01adb 1597 $classname = "qformat_$fileformat";
ff4b6492 1598 $format_class = new $classname();
1599 if ($type=='import') {
1600 $provided = $format_class->provide_import();
1601 }
1602 else {
1603 $provided = $format_class->provide_export();
1604 }
1605 if ($provided) {
1606 $formatname = get_string($fileformat, 'quiz');
1607 if ($formatname == "[[$fileformat]]") {
1608 $formatname = $fileformat; // Just use the raw folder name
1609 }
1610 $fileformatnames[$fileformat] = $formatname;
1611 }
1612 }
1613 natcasesort($fileformatnames);
1614
1615 return $fileformatnames;
1616}
feb60a07 1617
1618
1619/**
1620* Create default export filename
1621*
1622* @return string default export filename
1623* @param object $course
1624* @param object $category
1625*/
1626function default_export_filename($course,$category) {
1627 //Take off some characters in the filename !!
1628 $takeoff = array(" ", ":", "/", "\\", "|");
57f1b914 1629 $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz")));
feb60a07 1630 //If non-translated, use "export"
1631 if (substr($export_word,0,1) == "[") {
1632 $export_word= "export";
1633 }
1634
1635 //Calculate the date format string
1636 $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz"));
1637 //If non-translated, use "%Y%m%d-%H%M"
1638 if (substr($export_date_format,0,1) == "[") {
1639 $export_date_format = "%%Y%%m%%d-%%H%%M";
1640 }
1641
1642 //Calculate the shortname
1643 $export_shortname = clean_filename($course->shortname);
1644 if (empty($export_shortname) or $export_shortname == '_' ) {
1645 $export_shortname = $course->id;
1646 }
1647
1648 //Calculate the category name
1649 $export_categoryname = clean_filename($category->name);
1650
1651 //Calculate the final export filename
1652 //The export word
1653 $export_name = $export_word."-";
1654 //The shortname
57f1b914 1655 $export_name .= moodle_strtolower($export_shortname)."-";
feb60a07 1656 //The category name
57f1b914 1657 $export_name .= moodle_strtolower($export_categoryname)."-";
feb60a07 1658 //The date format
1659 $export_name .= userdate(time(),$export_date_format,99,false);
1660 //The extension - no extension, supplied by format
1661 // $export_name .= ".txt";
1662
1663 return $export_name;
1664}
1665
f5c15407 1666?>