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