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