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