[ Index ] |
PHP Cross Reference of moodle-2.8 |
[Summary view] [Print] [Text view]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * This file defines the question attempt class, and a few related classes. 19 * 20 * @package moodlecore 21 * @subpackage questionengine 22 * @copyright 2009 The Open University 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 */ 25 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 30 /** 31 * Tracks an attempt at one particular question in a {@link question_usage_by_activity}. 32 * 33 * Most calling code should need to access objects of this class. They should be 34 * able to do everything through the usage interface. This class is an internal 35 * implementation detail of the question engine. 36 * 37 * Instances of this class correspond to rows in the question_attempts table, and 38 * a collection of {@link question_attempt_steps}. Question inteaction models and 39 * question types do work with question_attempt objects. 40 * 41 * @copyright 2009 The Open University 42 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 43 */ 44 class question_attempt { 45 /** 46 * @var string this is a magic value that question types can return from 47 * {@link question_definition::get_expected_data()}. 48 */ 49 const USE_RAW_DATA = 'use raw data'; 50 51 /** 52 * @var string special value used by manual grading because {@link PARAM_FLOAT} 53 * converts '' to 0. 54 */ 55 const PARAM_MARK = 'parammark'; 56 57 /** 58 * @var string special value to indicate a response variable that is uploaded 59 * files. 60 */ 61 const PARAM_FILES = 'paramfiles'; 62 63 /** 64 * @var string special value to indicate a response variable that is uploaded 65 * files. 66 */ 67 const PARAM_RAW_FILES = 'paramrawfiles'; 68 69 /** 70 * @var string means first try at a question during an attempt by a user. 71 */ 72 const FIRST_TRY = 'firsttry'; 73 74 /** 75 * @var string means last try at a question during an attempt by a user. 76 */ 77 const LAST_TRY = 'lasttry'; 78 79 /** 80 * @var string means all tries at a question during an attempt by a user. 81 */ 82 const ALL_TRIES = 'alltries'; 83 84 /** @var integer if this attempts is stored in the question_attempts table, the id of that row. */ 85 protected $id = null; 86 87 /** @var integer|string the id of the question_usage_by_activity we belong to. */ 88 protected $usageid; 89 90 /** @var integer the number used to identify this question_attempt within the usage. */ 91 protected $slot = null; 92 93 /** 94 * @var question_behaviour the behaviour controlling this attempt. 95 * null until {@link start()} is called. 96 */ 97 protected $behaviour = null; 98 99 /** @var question_definition the question this is an attempt at. */ 100 protected $question; 101 102 /** @var int which variant of the question to use. */ 103 protected $variant; 104 105 /** 106 * @var float the maximum mark that can be scored at this question. 107 * Actually, this is only really a nominal maximum. It might be better thought 108 * of as the question weight. 109 */ 110 protected $maxmark; 111 112 /** 113 * @var float the minimum fraction that can be scored at this question, so 114 * the minimum mark is $this->minfraction * $this->maxmark. 115 */ 116 protected $minfraction = null; 117 118 /** 119 * @var float the maximum fraction that can be scored at this question, so 120 * the maximum mark is $this->maxfraction * $this->maxmark. 121 */ 122 protected $maxfraction = null; 123 124 /** 125 * @var string plain text summary of the variant of the question the 126 * student saw. Intended for reporting purposes. 127 */ 128 protected $questionsummary = null; 129 130 /** 131 * @var string plain text summary of the response the student gave. 132 * Intended for reporting purposes. 133 */ 134 protected $responsesummary = null; 135 136 /** 137 * @var string plain text summary of the correct response to this question 138 * variant the student saw. The format should be similar to responsesummary. 139 * Intended for reporting purposes. 140 */ 141 protected $rightanswer = null; 142 143 /** @var array of {@link question_attempt_step}s. The steps in this attempt. */ 144 protected $steps = array(); 145 146 /** 147 * @var question_attempt_step if, when we loaded the step from the DB, there was 148 * an autosaved step, we save a pointer to it here. (It is also added to the $steps array.) 149 */ 150 protected $autosavedstep = null; 151 152 /** @var boolean whether the user has flagged this attempt within the usage. */ 153 protected $flagged = false; 154 155 /** @var question_usage_observer tracks changes to the useage this attempt is part of.*/ 156 protected $observer; 157 158 /**#@+ 159 * Constants used by the intereaction models to indicate whether the current 160 * pending step should be kept or discarded. 161 */ 162 const KEEP = true; 163 const DISCARD = false; 164 /**#@-*/ 165 166 /** 167 * Create a new {@link question_attempt}. Normally you should create question_attempts 168 * indirectly, by calling {@link question_usage_by_activity::add_question()}. 169 * 170 * @param question_definition $question the question this is an attempt at. 171 * @param int|string $usageid The id of the 172 * {@link question_usage_by_activity} we belong to. Used by {@link get_field_prefix()}. 173 * @param question_usage_observer $observer tracks changes to the useage this 174 * attempt is part of. (Optional, a {@link question_usage_null_observer} is 175 * used if one is not passed. 176 * @param number $maxmark the maximum grade for this question_attempt. If not 177 * passed, $question->defaultmark is used. 178 */ 179 public function __construct(question_definition $question, $usageid, 180 question_usage_observer $observer = null, $maxmark = null) { 181 $this->question = $question; 182 $this->usageid = $usageid; 183 if (is_null($observer)) { 184 $observer = new question_usage_null_observer(); 185 } 186 $this->observer = $observer; 187 if (!is_null($maxmark)) { 188 $this->maxmark = $maxmark; 189 } else { 190 $this->maxmark = $question->defaultmark; 191 } 192 } 193 194 /** 195 * This method exists so that {@link question_attempt_with_restricted_history} 196 * can override it. You should not normally need to call it. 197 * @return question_attempt return ourself. 198 */ 199 public function get_full_qa() { 200 return $this; 201 } 202 203 /** @return question_definition the question this is an attempt at. */ 204 public function get_question() { 205 return $this->question; 206 } 207 208 /** 209 * Get the variant of the question being used in a given slot. 210 * @return int the variant number. 211 */ 212 public function get_variant() { 213 return $this->variant; 214 } 215 216 /** 217 * Set the number used to identify this question_attempt within the usage. 218 * For internal use only. 219 * @param int $slot 220 */ 221 public function set_slot($slot) { 222 $this->slot = $slot; 223 } 224 225 /** @return int the number used to identify this question_attempt within the usage. */ 226 public function get_slot() { 227 return $this->slot; 228 } 229 230 /** 231 * @return int the id of row for this question_attempt, if it is stored in the 232 * database. null if not. 233 */ 234 public function get_database_id() { 235 return $this->id; 236 } 237 238 /** 239 * For internal use only. Set the id of the corresponding database row. 240 * @param int $id the id of row for this question_attempt, if it is 241 * stored in the database. 242 */ 243 public function set_database_id($id) { 244 $this->id = $id; 245 } 246 247 /** 248 * You should almost certainly not call this method from your code. It is for 249 * internal use only. 250 * @param question_usage_observer that should be used to tracking changes made to this qa. 251 */ 252 public function set_observer($observer) { 253 $this->observer = $observer; 254 } 255 256 /** @return int|string the id of the {@link question_usage_by_activity} we belong to. */ 257 public function get_usage_id() { 258 return $this->usageid; 259 } 260 261 /** 262 * Set the id of the {@link question_usage_by_activity} we belong to. 263 * For internal use only. 264 * @param int|string the new id. 265 */ 266 public function set_usage_id($usageid) { 267 $this->usageid = $usageid; 268 } 269 270 /** @return string the name of the behaviour that is controlling this attempt. */ 271 public function get_behaviour_name() { 272 return $this->behaviour->get_name(); 273 } 274 275 /** 276 * For internal use only. 277 * @return question_behaviour the behaviour that is controlling this attempt. 278 */ 279 public function get_behaviour() { 280 return $this->behaviour; 281 } 282 283 /** 284 * Set the flagged state of this question. 285 * @param bool $flagged the new state. 286 */ 287 public function set_flagged($flagged) { 288 $this->flagged = $flagged; 289 $this->observer->notify_attempt_modified($this); 290 } 291 292 /** @return bool whether this question is currently flagged. */ 293 public function is_flagged() { 294 return $this->flagged; 295 } 296 297 /** 298 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable 299 * name) to use for the field that indicates whether this question is flagged. 300 * 301 * @return string The field name to use. 302 */ 303 public function get_flag_field_name() { 304 return $this->get_control_field_name('flagged'); 305 } 306 307 /** 308 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable 309 * name) to use for a question_type variable belonging to this question_attempt. 310 * 311 * See the comment on {@link question_attempt_step} for an explanation of 312 * question type and behaviour variables. 313 * 314 * @param $varname The short form of the variable name. 315 * @return string The field name to use. 316 */ 317 public function get_qt_field_name($varname) { 318 return $this->get_field_prefix() . $varname; 319 } 320 321 /** 322 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable 323 * name) to use for a question_type variable belonging to this question_attempt. 324 * 325 * See the comment on {@link question_attempt_step} for an explanation of 326 * question type and behaviour variables. 327 * 328 * @param $varname The short form of the variable name. 329 * @return string The field name to use. 330 */ 331 public function get_behaviour_field_name($varname) { 332 return $this->get_field_prefix() . '-' . $varname; 333 } 334 335 /** 336 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable 337 * name) to use for a control variables belonging to this question_attempt. 338 * 339 * Examples are :sequencecheck and :flagged 340 * 341 * @param $varname The short form of the variable name. 342 * @return string The field name to use. 343 */ 344 public function get_control_field_name($varname) { 345 return $this->get_field_prefix() . ':' . $varname; 346 } 347 348 /** 349 * Get the prefix added to variable names to give field names for this 350 * question attempt. 351 * 352 * You should not use this method directly. This is an implementation detail 353 * anyway, but if you must access it, use {@link question_usage_by_activity::get_field_prefix()}. 354 * 355 * @param $varname The short form of the variable name. 356 * @return string The field name to use. 357 */ 358 public function get_field_prefix() { 359 return 'q' . $this->usageid . ':' . $this->slot . '_'; 360 } 361 362 /** 363 * Get one of the steps in this attempt. 364 * 365 * @param int $i the step number. 366 * @return question_attempt_step 367 */ 368 public function get_step($i) { 369 if ($i < 0 || $i >= count($this->steps)) { 370 throw new coding_exception('Index out of bounds in question_attempt::get_step.'); 371 } 372 return $this->steps[$i]; 373 } 374 375 /** 376 * Get the number of real steps in this attempt. 377 * This is put as a hidden field in the HTML, so that when we receive some 378 * data to process, then we can check that it came from the question 379 * in the state we are now it. 380 * @return int a number that summarises the current state of this question attempt. 381 */ 382 public function get_sequence_check_count() { 383 $numrealsteps = $this->get_num_steps(); 384 if ($this->has_autosaved_step()) { 385 $numrealsteps -= 1; 386 } 387 return $numrealsteps; 388 } 389 390 /** 391 * Get the number of steps in this attempt. 392 * For internal/test code use only. 393 * @return int the number of steps we currently have. 394 */ 395 public function get_num_steps() { 396 return count($this->steps); 397 } 398 399 /** 400 * Return the latest step in this question_attempt. 401 * For internal/test code use only. 402 * @return question_attempt_step 403 */ 404 public function get_last_step() { 405 if (count($this->steps) == 0) { 406 return new question_null_step(); 407 } 408 return end($this->steps); 409 } 410 411 /** 412 * @return boolean whether this question_attempt has autosaved data from 413 * some time in the past. 414 */ 415 public function has_autosaved_step() { 416 return !is_null($this->autosavedstep); 417 } 418 419 /** 420 * @return question_attempt_step_iterator for iterating over the steps in 421 * this attempt, in order. 422 */ 423 public function get_step_iterator() { 424 return new question_attempt_step_iterator($this); 425 } 426 427 /** 428 * The same as {@link get_step_iterator()}. However, for a 429 * {@link question_attempt_with_restricted_history} this returns the full 430 * list of steps, while {@link get_step_iterator()} returns only the 431 * limited history. 432 * @return question_attempt_step_iterator for iterating over the steps in 433 * this attempt, in order. 434 */ 435 public function get_full_step_iterator() { 436 return $this->get_step_iterator(); 437 } 438 439 /** 440 * @return question_attempt_reverse_step_iterator for iterating over the steps in 441 * this attempt, in reverse order. 442 */ 443 public function get_reverse_step_iterator() { 444 return new question_attempt_reverse_step_iterator($this); 445 } 446 447 /** 448 * Get the qt data from the latest step that has any qt data. Return $default 449 * array if it is no step has qt data. 450 * 451 * @param string $name the name of the variable to get. 452 * @param mixed default the value to return no step has qt data. 453 * (Optional, defaults to an empty array.) 454 * @return array|mixed the data, or $default if there is not any. 455 */ 456 public function get_last_qt_data($default = array()) { 457 foreach ($this->get_reverse_step_iterator() as $step) { 458 $response = $step->get_qt_data(); 459 if (!empty($response)) { 460 return $response; 461 } 462 } 463 return $default; 464 } 465 466 /** 467 * Get the last step with a particular question type varialbe set. 468 * @param string $name the name of the variable to get. 469 * @return question_attempt_step the last step, or a step with no variables 470 * if there was not a real step. 471 */ 472 public function get_last_step_with_qt_var($name) { 473 foreach ($this->get_reverse_step_iterator() as $step) { 474 if ($step->has_qt_var($name)) { 475 return $step; 476 } 477 } 478 return new question_attempt_step_read_only(); 479 } 480 481 /** 482 * Get the last step with a particular behaviour variable set. 483 * @param string $name the name of the variable to get. 484 * @return question_attempt_step the last step, or a step with no variables 485 * if there was not a real step. 486 */ 487 public function get_last_step_with_behaviour_var($name) { 488 foreach ($this->get_reverse_step_iterator() as $step) { 489 if ($step->has_behaviour_var($name)) { 490 return $step; 491 } 492 } 493 return new question_attempt_step_read_only(); 494 } 495 496 /** 497 * Get the latest value of a particular question type variable. That is, get 498 * the value from the latest step that has it set. Return null if it is not 499 * set in any step. 500 * 501 * @param string $name the name of the variable to get. 502 * @param mixed default the value to return in the variable has never been set. 503 * (Optional, defaults to null.) 504 * @return mixed string value, or $default if it has never been set. 505 */ 506 public function get_last_qt_var($name, $default = null) { 507 $step = $this->get_last_step_with_qt_var($name); 508 if ($step->has_qt_var($name)) { 509 return $step->get_qt_var($name); 510 } else { 511 return $default; 512 } 513 } 514 515 /** 516 * Get the latest set of files for a particular question type variable of 517 * type question_attempt::PARAM_FILES. 518 * 519 * @param string $name the name of the associated variable. 520 * @return array of {@link stored_files}. 521 */ 522 public function get_last_qt_files($name, $contextid) { 523 foreach ($this->get_reverse_step_iterator() as $step) { 524 if ($step->has_qt_var($name)) { 525 return $step->get_qt_files($name, $contextid); 526 } 527 } 528 return array(); 529 } 530 531 /** 532 * Get the URL of a file that belongs to a response variable of this 533 * question_attempt. 534 * @param stored_file $file the file to link to. 535 * @return string the URL of that file. 536 */ 537 public function get_response_file_url(stored_file $file) { 538 return file_encode_url(new moodle_url('/pluginfile.php'), '/' . implode('/', array( 539 $file->get_contextid(), 540 $file->get_component(), 541 $file->get_filearea(), 542 $this->usageid, 543 $this->slot, 544 $file->get_itemid())) . 545 $file->get_filepath() . $file->get_filename(), true); 546 } 547 548 /** 549 * Prepare a draft file are for the files belonging the a response variable 550 * of this question attempt. The draft area is populated with the files from 551 * the most recent step having files. 552 * 553 * @param string $name the variable name the files belong to. 554 * @param int $contextid the id of the context the quba belongs to. 555 * @return int the draft itemid. 556 */ 557 public function prepare_response_files_draft_itemid($name, $contextid) { 558 foreach ($this->get_reverse_step_iterator() as $step) { 559 if ($step->has_qt_var($name)) { 560 return $step->prepare_response_files_draft_itemid($name, $contextid); 561 } 562 } 563 564 // No files yet. 565 $draftid = 0; // Will be filled in by file_prepare_draft_area. 566 file_prepare_draft_area($draftid, $contextid, 'question', 'response_' . $name, null); 567 return $draftid; 568 } 569 570 /** 571 * Get the latest value of a particular behaviour variable. That is, 572 * get the value from the latest step that has it set. Return null if it is 573 * not set in any step. 574 * 575 * @param string $name the name of the variable to get. 576 * @param mixed default the value to return in the variable has never been set. 577 * (Optional, defaults to null.) 578 * @return mixed string value, or $default if it has never been set. 579 */ 580 public function get_last_behaviour_var($name, $default = null) { 581 foreach ($this->get_reverse_step_iterator() as $step) { 582 if ($step->has_behaviour_var($name)) { 583 return $step->get_behaviour_var($name); 584 } 585 } 586 return $default; 587 } 588 589 /** 590 * Get the current state of this question attempt. That is, the state of the 591 * latest step. 592 * @return question_state 593 */ 594 public function get_state() { 595 return $this->get_last_step()->get_state(); 596 } 597 598 /** 599 * @param bool $showcorrectness Whether right/partial/wrong states should 600 * be distinguised. 601 * @return string A brief textual description of the current state. 602 */ 603 public function get_state_string($showcorrectness) { 604 // Special case when attempt is based on previous one, see MDL-31226. 605 if ($this->get_num_steps() == 1 && $this->get_state() == question_state::$complete) { 606 return get_string('notchanged', 'question'); 607 } 608 return $this->behaviour->get_state_string($showcorrectness); 609 } 610 611 /** 612 * @param bool $showcorrectness Whether right/partial/wrong states should 613 * be distinguised. 614 * @return string a CSS class name for the current state. 615 */ 616 public function get_state_class($showcorrectness) { 617 return $this->get_state()->get_state_class($showcorrectness); 618 } 619 620 /** 621 * @return int the timestamp of the most recent step in this question attempt. 622 */ 623 public function get_last_action_time() { 624 return $this->get_last_step()->get_timecreated(); 625 } 626 627 /** 628 * Get the current fraction of this question attempt. That is, the fraction 629 * of the latest step, or null if this question has not yet been graded. 630 * @return number the current fraction. 631 */ 632 public function get_fraction() { 633 return $this->get_last_step()->get_fraction(); 634 } 635 636 /** @return bool whether this question attempt has a non-zero maximum mark. */ 637 public function has_marks() { 638 // Since grades are stored in the database as NUMBER(12,7). 639 return $this->maxmark >= 0.00000005; 640 } 641 642 /** 643 * @return number the current mark for this question. 644 * {@link get_fraction()} * {@link get_max_mark()}. 645 */ 646 public function get_mark() { 647 return $this->fraction_to_mark($this->get_fraction()); 648 } 649 650 /** 651 * This is used by the manual grading code, particularly in association with 652 * validation. If there is a mark submitted in the request, then use that, 653 * otherwise use the latest mark for this question. 654 * @return number the current mark for this question. 655 * {@link get_fraction()} * {@link get_max_mark()}. 656 */ 657 public function get_current_manual_mark() { 658 $mark = $this->get_submitted_var($this->get_behaviour_field_name('mark'), question_attempt::PARAM_MARK); 659 if (is_null($mark)) { 660 return $this->get_mark(); 661 } else { 662 return $mark; 663 } 664 } 665 666 /** 667 * @param number|null $fraction a fraction. 668 * @return number|null the corresponding mark. 669 */ 670 public function fraction_to_mark($fraction) { 671 if (is_null($fraction)) { 672 return null; 673 } 674 return $fraction * $this->maxmark; 675 } 676 677 /** 678 * @return float the maximum mark possible for this question attempt. 679 * In fact, this is not strictly the maximum, becuase get_max_fraction may 680 * return a number greater than 1. It might be better to think of this as a 681 * question weight. 682 */ 683 public function get_max_mark() { 684 return $this->maxmark; 685 } 686 687 /** @return float the maximum mark possible for this question attempt. */ 688 public function get_min_fraction() { 689 if (is_null($this->minfraction)) { 690 throw new coding_exception('This question_attempt has not been started yet, the min fraction is not yet known.'); 691 } 692 return $this->minfraction; 693 } 694 695 /** @return float the maximum mark possible for this question attempt. */ 696 public function get_max_fraction() { 697 if (is_null($this->maxfraction)) { 698 throw new coding_exception('This question_attempt has not been started yet, the max fraction is not yet known.'); 699 } 700 return $this->maxfraction; 701 } 702 703 /** 704 * The current mark, formatted to the stated number of decimal places. Uses 705 * {@link format_float()} to format floats according to the current locale. 706 * @param int $dp number of decimal places. 707 * @return string formatted mark. 708 */ 709 public function format_mark($dp) { 710 return $this->format_fraction_as_mark($this->get_fraction(), $dp); 711 } 712 713 /** 714 * The current mark, formatted to the stated number of decimal places. Uses 715 * {@link format_float()} to format floats according to the current locale. 716 * @param int $dp number of decimal places. 717 * @return string formatted mark. 718 */ 719 public function format_fraction_as_mark($fraction, $dp) { 720 return format_float($this->fraction_to_mark($fraction), $dp); 721 } 722 723 /** 724 * The maximum mark for this question attempt, formatted to the stated number 725 * of decimal places. Uses {@link format_float()} to format floats according 726 * to the current locale. 727 * @param int $dp number of decimal places. 728 * @return string formatted maximum mark. 729 */ 730 public function format_max_mark($dp) { 731 return format_float($this->maxmark, $dp); 732 } 733 734 /** 735 * Return the hint that applies to the question in its current state, or null. 736 * @return question_hint|null 737 */ 738 public function get_applicable_hint() { 739 return $this->behaviour->get_applicable_hint(); 740 } 741 742 /** 743 * Produce a plain-text summary of what the user did during a step. 744 * @param question_attempt_step $step the step in quetsion. 745 * @return string a summary of what was done during that step. 746 */ 747 public function summarise_action(question_attempt_step $step) { 748 return $this->behaviour->summarise_action($step); 749 } 750 751 /** 752 * Helper function used by {@link rewrite_pluginfile_urls()} and 753 * {@link rewrite_response_pluginfile_urls()}. 754 * @return array ids that need to go into the file paths. 755 */ 756 protected function extra_file_path_components() { 757 return array($this->get_usage_id(), $this->get_slot()); 758 } 759 760 /** 761 * Calls {@link question_rewrite_question_urls()} with appropriate parameters 762 * for content belonging to this question. 763 * @param string $text the content to output. 764 * @param string $component the component name (normally 'question' or 'qtype_...') 765 * @param string $filearea the name of the file area. 766 * @param int $itemid the item id. 767 * @return srting the content with the URLs rewritten. 768 */ 769 public function rewrite_pluginfile_urls($text, $component, $filearea, $itemid) { 770 return question_rewrite_question_urls($text, 'pluginfile.php', 771 $this->question->contextid, $component, $filearea, 772 $this->extra_file_path_components(), $itemid); 773 } 774 775 /** 776 * Calls {@link question_rewrite_question_urls()} with appropriate parameters 777 * for content belonging to responses to this question. 778 * 779 * @param string $text the text to update the URLs in. 780 * @param int $contextid the id of the context the quba belongs to. 781 * @param string $name the variable name the files belong to. 782 * @param question_attempt_step $step the step the response is coming from. 783 * @return srting the content with the URLs rewritten. 784 */ 785 public function rewrite_response_pluginfile_urls($text, $contextid, $name, 786 question_attempt_step $step) { 787 return $step->rewrite_response_pluginfile_urls($text, $contextid, $name, 788 $this->extra_file_path_components()); 789 } 790 791 /** 792 * Get the {@link core_question_renderer}, in collaboration with appropriate 793 * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the 794 * HTML to display this question attempt in its current state. 795 * @param question_display_options $options controls how the question is rendered. 796 * @param string|null $number The question number to display. 797 * @return string HTML fragment representing the question. 798 */ 799 public function render($options, $number, $page = null) { 800 if (is_null($page)) { 801 global $PAGE; 802 $page = $PAGE; 803 } 804 $qoutput = $page->get_renderer('core', 'question'); 805 $qtoutput = $this->question->get_renderer($page); 806 return $this->behaviour->render($options, $number, $qoutput, $qtoutput); 807 } 808 809 /** 810 * Generate any bits of HTML that needs to go in the <head> tag when this question 811 * attempt is displayed in the body. 812 * @return string HTML fragment. 813 */ 814 public function render_head_html($page = null) { 815 if (is_null($page)) { 816 global $PAGE; 817 $page = $PAGE; 818 } 819 // TODO go via behaviour. 820 return $this->question->get_renderer($page)->head_code($this) . 821 $this->behaviour->get_renderer($page)->head_code($this); 822 } 823 824 /** 825 * Like {@link render_question()} but displays the question at the past step 826 * indicated by $seq, rather than showing the latest step. 827 * 828 * @param int $seq the seq number of the past state to display. 829 * @param question_display_options $options controls how the question is rendered. 830 * @param string|null $number The question number to display. 'i' is a special 831 * value that gets displayed as Information. Null means no number is displayed. 832 * @return string HTML fragment representing the question. 833 */ 834 public function render_at_step($seq, $options, $number, $preferredbehaviour) { 835 $restrictedqa = new question_attempt_with_restricted_history($this, $seq, $preferredbehaviour); 836 return $restrictedqa->render($options, $number); 837 } 838 839 /** 840 * Checks whether the users is allow to be served a particular file. 841 * @param question_display_options $options the options that control display of the question. 842 * @param string $component the name of the component we are serving files for. 843 * @param string $filearea the name of the file area. 844 * @param array $args the remaining bits of the file path. 845 * @param bool $forcedownload whether the user must be forced to download the file. 846 * @return bool true if the user can access this file. 847 */ 848 public function check_file_access($options, $component, $filearea, $args, $forcedownload) { 849 return $this->behaviour->check_file_access($options, $component, $filearea, $args, $forcedownload); 850 } 851 852 /** 853 * Add a step to this question attempt. 854 * @param question_attempt_step $step the new step. 855 */ 856 protected function add_step(question_attempt_step $step) { 857 $this->steps[] = $step; 858 end($this->steps); 859 $this->observer->notify_step_added($step, $this, key($this->steps)); 860 } 861 862 /** 863 * Add an auto-saved step to this question attempt. We mark auto-saved steps by 864 * changing saving the step number with a - sign. 865 * @param question_attempt_step $step the new step. 866 */ 867 protected function add_autosaved_step(question_attempt_step $step) { 868 $this->steps[] = $step; 869 $this->autosavedstep = $step; 870 end($this->steps); 871 $this->observer->notify_step_added($step, $this, -key($this->steps)); 872 } 873 874 /** 875 * Discard any auto-saved data belonging to this question attempt. 876 */ 877 public function discard_autosaved_step() { 878 if (!$this->has_autosaved_step()) { 879 return; 880 } 881 882 $autosaved = array_pop($this->steps); 883 $this->autosavedstep = null; 884 $this->observer->notify_step_deleted($autosaved, $this); 885 } 886 887 /** 888 * If there is an autosaved step, convert it into a real save, so that it 889 * is preserved. 890 */ 891 protected function convert_autosaved_step_to_real_step() { 892 if ($this->autosavedstep === null) { 893 return; 894 } 895 896 $laststep = end($this->steps); 897 if ($laststep !== $this->autosavedstep) { 898 throw new coding_exception('Cannot convert autosaved step to real step, since other steps have been added.'); 899 } 900 901 $this->observer->notify_step_modified($this->autosavedstep, $this, key($this->steps)); 902 $this->autosavedstep = null; 903 } 904 905 /** 906 * Use a strategy to pick a variant. 907 * @param question_variant_selection_strategy $variantstrategy a strategy. 908 * @return int the selected variant. 909 */ 910 public function select_variant(question_variant_selection_strategy $variantstrategy) { 911 return $variantstrategy->choose_variant($this->get_question()->get_num_variants(), 912 $this->get_question()->get_variants_selection_seed()); 913 } 914 915 /** 916 * Start this question attempt. 917 * 918 * You should not call this method directly. Call 919 * {@link question_usage_by_activity::start_question()} instead. 920 * 921 * @param string|question_behaviour $preferredbehaviour the name of the 922 * desired archetypal behaviour, or an actual model instance. 923 * @param int $variant the variant of the question to start. Between 1 and 924 * $this->get_question()->get_num_variants() inclusive. 925 * @param array $submitteddata optional, used when re-starting to keep the same initial state. 926 * @param int $timestamp optional, the timstamp to record for this action. Defaults to now. 927 * @param int $userid optional, the user to attribute this action to. Defaults to the current user. 928 * @param int $existingstepid optional, if this step is going to replace an existing step 929 * (for example, during a regrade) this is the id of the previous step we are replacing. 930 */ 931 public function start($preferredbehaviour, $variant, $submitteddata = array(), 932 $timestamp = null, $userid = null, $existingstepid = null) { 933 934 // Initialise the behaviour. 935 $this->variant = $variant; 936 if (is_string($preferredbehaviour)) { 937 $this->behaviour = 938 $this->question->make_behaviour($this, $preferredbehaviour); 939 } else { 940 $class = get_class($preferredbehaviour); 941 $this->behaviour = new $class($this, $preferredbehaviour); 942 } 943 944 // Record the minimum and maximum fractions. 945 $this->minfraction = $this->behaviour->get_min_fraction(); 946 $this->maxfraction = $this->behaviour->get_max_fraction(); 947 948 // Initialise the first step. 949 $firststep = new question_attempt_step($submitteddata, $timestamp, $userid, $existingstepid); 950 if ($submitteddata) { 951 $firststep->set_state(question_state::$complete); 952 $this->behaviour->apply_attempt_state($firststep); 953 } else { 954 $this->behaviour->init_first_step($firststep, $variant); 955 } 956 $this->add_step($firststep); 957 958 // Record questionline and correct answer. 959 $this->questionsummary = $this->behaviour->get_question_summary(); 960 $this->rightanswer = $this->behaviour->get_right_answer_summary(); 961 } 962 963 /** 964 * Start this question attempt, starting from the point that the previous 965 * attempt $oldqa had reached. 966 * 967 * You should not call this method directly. Call 968 * {@link question_usage_by_activity::start_question_based_on()} instead. 969 * 970 * @param question_attempt $oldqa a previous attempt at this quetsion that 971 * defines the starting point. 972 */ 973 public function start_based_on(question_attempt $oldqa) { 974 $this->start($oldqa->behaviour, $oldqa->get_variant(), $oldqa->get_resume_data()); 975 } 976 977 /** 978 * Used by {@link start_based_on()} to get the data needed to start a new 979 * attempt from the point this attempt has go to. 980 * @return array name => value pairs. 981 */ 982 protected function get_resume_data() { 983 $resumedata = $this->behaviour->get_resume_data(); 984 foreach ($resumedata as $name => $value) { 985 if ($value instanceof question_file_loader) { 986 $resumedata[$name] = $value->get_question_file_saver(); 987 } 988 } 989 return $resumedata; 990 } 991 992 /** 993 * Get a particular parameter from the current request. A wrapper round 994 * {@link optional_param()}, except that the results is returned without 995 * slashes. 996 * @param string $name the paramter name. 997 * @param int $type one of the standard PARAM_... constants, or one of the 998 * special extra constands defined by this class. 999 * @param array $postdata (optional, only inteded for testing use) take the 1000 * data from this array, instead of from $_POST. 1001 * @return mixed the requested value. 1002 */ 1003 public function get_submitted_var($name, $type, $postdata = null) { 1004 switch ($type) { 1005 case self::PARAM_MARK: 1006 // Special case to work around PARAM_FLOAT converting '' to 0. 1007 return question_utils::clean_param_mark($this->get_submitted_var($name, PARAM_RAW_TRIMMED, $postdata)); 1008 1009 case self::PARAM_FILES: 1010 return $this->process_response_files($name, $name, $postdata); 1011 1012 case self::PARAM_RAW_FILES: 1013 $var = $this->get_submitted_var($name, PARAM_RAW, $postdata); 1014 return $this->process_response_files($name, $name . ':itemid', $postdata, $var); 1015 1016 default: 1017 if (is_null($postdata)) { 1018 $var = optional_param($name, null, $type); 1019 } else if (array_key_exists($name, $postdata)) { 1020 $var = clean_param($postdata[$name], $type); 1021 } else { 1022 $var = null; 1023 } 1024 1025 return $var; 1026 } 1027 } 1028 1029 /** 1030 * Handle a submitted variable representing uploaded files. 1031 * @param string $name the field name. 1032 * @param string $draftidname the field name holding the draft file area id. 1033 * @param array $postdata (optional, only inteded for testing use) take the 1034 * data from this array, instead of from $_POST. At the moment, this 1035 * behaves as if there were no files. 1036 * @param string $text optional reponse text. 1037 * @return question_file_saver that can be used to save the files later. 1038 */ 1039 protected function process_response_files($name, $draftidname, $postdata = null, $text = null) { 1040 if ($postdata) { 1041 // For simulated posts, get the draft itemid from there. 1042 $draftitemid = $this->get_submitted_var($draftidname, PARAM_INT, $postdata); 1043 } else { 1044 $draftitemid = file_get_submitted_draft_itemid($draftidname); 1045 } 1046 1047 if (!$draftitemid) { 1048 return null; 1049 } 1050 1051 return new question_file_saver($draftitemid, 'question', 'response_' . 1052 str_replace($this->get_field_prefix(), '', $name), $text); 1053 } 1054 1055 /** 1056 * Get any data from the request that matches the list of expected params. 1057 * @param array $expected variable name => PARAM_... constant. 1058 * @param string $extraprefix '-' or ''. 1059 * @return array name => value. 1060 */ 1061 protected function get_expected_data($expected, $postdata, $extraprefix) { 1062 $submitteddata = array(); 1063 foreach ($expected as $name => $type) { 1064 $value = $this->get_submitted_var( 1065 $this->get_field_prefix() . $extraprefix . $name, $type, $postdata); 1066 if (!is_null($value)) { 1067 $submitteddata[$extraprefix . $name] = $value; 1068 } 1069 } 1070 return $submitteddata; 1071 } 1072 1073 /** 1074 * Get all the submitted question type data for this question, whithout checking 1075 * that it is valid or cleaning it in any way. 1076 * @return array name => value. 1077 */ 1078 public function get_all_submitted_qt_vars($postdata) { 1079 if (is_null($postdata)) { 1080 $postdata = $_POST; 1081 } 1082 1083 $pattern = '/^' . preg_quote($this->get_field_prefix(), '/') . '[^-:]/'; 1084 $prefixlen = strlen($this->get_field_prefix()); 1085 1086 $submitteddata = array(); 1087 foreach ($postdata as $name => $value) { 1088 if (preg_match($pattern, $name)) { 1089 $submitteddata[substr($name, $prefixlen)] = $value; 1090 } 1091 } 1092 1093 return $submitteddata; 1094 } 1095 1096 /** 1097 * Get all the sumbitted data belonging to this question attempt from the 1098 * current request. 1099 * @param array $postdata (optional, only inteded for testing use) take the 1100 * data from this array, instead of from $_POST. 1101 * @return array name => value pairs that could be passed to {@link process_action()}. 1102 */ 1103 public function get_submitted_data($postdata = null) { 1104 $submitteddata = $this->get_expected_data( 1105 $this->behaviour->get_expected_data(), $postdata, '-'); 1106 1107 $expected = $this->behaviour->get_expected_qt_data(); 1108 if ($expected === self::USE_RAW_DATA) { 1109 $submitteddata += $this->get_all_submitted_qt_vars($postdata); 1110 } else { 1111 $submitteddata += $this->get_expected_data($expected, $postdata, ''); 1112 } 1113 return $submitteddata; 1114 } 1115 1116 /** 1117 * Get a set of response data for this question attempt that would get the 1118 * best possible mark. If it is not possible to compute a correct 1119 * response, this method should return null. 1120 * @return array|null name => value pairs that could be passed to {@link process_action()}. 1121 */ 1122 public function get_correct_response() { 1123 $response = $this->question->get_correct_response(); 1124 if (is_null($response)) { 1125 return null; 1126 } 1127 $imvars = $this->behaviour->get_correct_response(); 1128 foreach ($imvars as $name => $value) { 1129 $response['-' . $name] = $value; 1130 } 1131 return $response; 1132 } 1133 1134 /** 1135 * Change the quetsion summary. Note, that this is almost never necessary. 1136 * This method was only added to work around a limitation of the Opaque 1137 * protocol, which only sends questionLine at the end of an attempt. 1138 * @param $questionsummary the new summary to set. 1139 */ 1140 public function set_question_summary($questionsummary) { 1141 $this->questionsummary = $questionsummary; 1142 $this->observer->notify_attempt_modified($this); 1143 } 1144 1145 /** 1146 * @return string a simple textual summary of the question that was asked. 1147 */ 1148 public function get_question_summary() { 1149 return $this->questionsummary; 1150 } 1151 1152 /** 1153 * @return string a simple textual summary of response given. 1154 */ 1155 public function get_response_summary() { 1156 return $this->responsesummary; 1157 } 1158 1159 /** 1160 * @return string a simple textual summary of the correct resonse. 1161 */ 1162 public function get_right_answer_summary() { 1163 return $this->rightanswer; 1164 } 1165 1166 /** 1167 * Perform the action described by $submitteddata. 1168 * @param array $submitteddata the submitted data the determines the action. 1169 * @param int $timestamp the time to record for the action. (If not given, use now.) 1170 * @param int $userid the user to attribute the action to. (If not given, use the current user.) 1171 * @param int $existingstepid used by the regrade code. 1172 */ 1173 public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) { 1174 $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid, $existingstepid); 1175 $this->discard_autosaved_step(); 1176 if ($this->behaviour->process_action($pendingstep) == self::KEEP) { 1177 $this->add_step($pendingstep); 1178 if ($pendingstep->response_summary_changed()) { 1179 $this->responsesummary = $pendingstep->get_new_response_summary(); 1180 } 1181 if ($pendingstep->variant_number_changed()) { 1182 $this->variant = $pendingstep->get_new_variant_number(); 1183 } 1184 } 1185 } 1186 1187 /** 1188 * Process an autosave. 1189 * @param array $submitteddata the submitted data the determines the action. 1190 * @param int $timestamp the time to record for the action. (If not given, use now.) 1191 * @param int $userid the user to attribute the action to. (If not given, use the current user.) 1192 * @return bool whether anything was saved. 1193 */ 1194 public function process_autosave($submitteddata, $timestamp = null, $userid = null) { 1195 $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid); 1196 if ($this->behaviour->process_autosave($pendingstep) == self::KEEP) { 1197 $this->add_autosaved_step($pendingstep); 1198 return true; 1199 } 1200 return false; 1201 } 1202 1203 /** 1204 * Perform a finish action on this question attempt. This corresponds to an 1205 * external finish action, for example the user pressing Submit all and finish 1206 * in the quiz, rather than using one of the controls that is part of the 1207 * question. 1208 * 1209 * @param int $timestamp the time to record for the action. (If not given, use now.) 1210 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.) 1211 */ 1212 public function finish($timestamp = null, $userid = null) { 1213 $this->convert_autosaved_step_to_real_step(); 1214 $this->process_action(array('-finish' => 1), $timestamp, $userid); 1215 } 1216 1217 /** 1218 * Perform a regrade. This replays all the actions from $oldqa into this 1219 * attempt. 1220 * @param question_attempt $oldqa the attempt to regrade. 1221 * @param bool $finished whether the question attempt should be forced to be finished 1222 * after the regrade, or whether it may still be in progress (default false). 1223 */ 1224 public function regrade(question_attempt $oldqa, $finished) { 1225 $first = true; 1226 foreach ($oldqa->get_step_iterator() as $step) { 1227 $this->observer->notify_step_deleted($step, $this); 1228 1229 if ($first) { 1230 // First step of the attempt. 1231 $first = false; 1232 $this->start($oldqa->behaviour, $oldqa->get_variant(), $step->get_all_data(), 1233 $step->get_timecreated(), $step->get_user_id(), $step->get_id()); 1234 1235 } else if ($step->has_behaviour_var('finish') && count($step->get_submitted_data()) > 1) { 1236 // This case relates to MDL-32062. The upgrade code from 2.0 1237 // generates attempts where the final submit of the question 1238 // data, and the finish action, are in the same step. The system 1239 // cannot cope with that, so convert the single old step into 1240 // two new steps. 1241 $submitteddata = $step->get_submitted_data(); 1242 unset($submitteddata['-finish']); 1243 $this->process_action($submitteddata, 1244 $step->get_timecreated(), $step->get_user_id(), $step->get_id()); 1245 $this->finish($step->get_timecreated(), $step->get_user_id()); 1246 1247 } else { 1248 // This is the normal case. Replay the next step of the attempt. 1249 $this->process_action($step->get_submitted_data(), 1250 $step->get_timecreated(), $step->get_user_id(), $step->get_id()); 1251 } 1252 } 1253 1254 if ($finished) { 1255 $this->finish(); 1256 } 1257 } 1258 1259 /** 1260 * Perform a manual grading action on this attempt. 1261 * @param string $comment the comment being added. 1262 * @param float $mark the new mark. If null, then only a comment is added. 1263 * @param int $commentformat the FORMAT_... for $comment. Must be given. 1264 * @param int $timestamp the time to record for the action. (If not given, use now.) 1265 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.) 1266 */ 1267 public function manual_grade($comment, $mark, $commentformat = null, $timestamp = null, $userid = null) { 1268 $submitteddata = array('-comment' => $comment); 1269 if (is_null($commentformat)) { 1270 debugging('You should pass $commentformat to manual_grade.', DEBUG_DEVELOPER); 1271 $commentformat = FORMAT_HTML; 1272 } 1273 $submitteddata['-commentformat'] = $commentformat; 1274 if (!is_null($mark)) { 1275 $submitteddata['-mark'] = $mark; 1276 $submitteddata['-maxmark'] = $this->maxmark; 1277 } 1278 $this->process_action($submitteddata, $timestamp, $userid); 1279 } 1280 1281 /** @return bool Whether this question attempt has had a manual comment added. */ 1282 public function has_manual_comment() { 1283 foreach ($this->steps as $step) { 1284 if ($step->has_behaviour_var('comment')) { 1285 return true; 1286 } 1287 } 1288 return false; 1289 } 1290 1291 /** 1292 * @return array(string, int) the most recent manual comment that was added 1293 * to this question, and the FORMAT_... it is. 1294 */ 1295 public function get_manual_comment() { 1296 foreach ($this->get_reverse_step_iterator() as $step) { 1297 if ($step->has_behaviour_var('comment')) { 1298 return array($step->get_behaviour_var('comment'), 1299 $step->get_behaviour_var('commentformat')); 1300 } 1301 } 1302 return array(null, null); 1303 } 1304 1305 /** 1306 * This is used by the manual grading code, particularly in association with 1307 * validation. If there is a comment submitted in the request, then use that, 1308 * otherwise use the latest comment for this question. 1309 * @return number the current mark for this question. 1310 * {@link get_fraction()} * {@link get_max_mark()}. 1311 */ 1312 public function get_current_manual_comment() { 1313 $comment = $this->get_submitted_var($this->get_behaviour_field_name('comment'), PARAM_RAW); 1314 if (is_null($comment)) { 1315 return $this->get_manual_comment(); 1316 } else { 1317 $commentformat = $this->get_submitted_var( 1318 $this->get_behaviour_field_name('commentformat'), PARAM_INT); 1319 if ($commentformat === null) { 1320 $commentformat = FORMAT_HTML; 1321 } 1322 return array($comment, $commentformat); 1323 } 1324 } 1325 1326 /** 1327 * Break down a student response by sub part and classification. See also {@link question::classify_response}. 1328 * Used for response analysis. 1329 * 1330 * @param string $whichtries which tries to analyse for response analysis. Will be one of 1331 * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. 1332 * Defaults to question_attempt::LAST_TRY. 1333 * @return (question_classified_response|array)[] If $whichtries is question_attempt::FIRST_TRY or LAST_TRY index is subpartid 1334 * and values are question_classified_response instances. 1335 * If $whichtries is question_attempt::ALL_TRIES then first key is submitted response no 1336 * and the second key is subpartid. 1337 */ 1338 public function classify_response($whichtries = self::LAST_TRY) { 1339 return $this->behaviour->classify_response($whichtries); 1340 } 1341 1342 /** 1343 * Create a question_attempt_step from records loaded from the database. 1344 * 1345 * For internal use only. 1346 * 1347 * @param Iterator $records Raw records loaded from the database. 1348 * @param int $questionattemptid The id of the question_attempt to extract. 1349 * @return question_attempt The newly constructed question_attempt. 1350 */ 1351 public static function load_from_records($records, $questionattemptid, 1352 question_usage_observer $observer, $preferredbehaviour) { 1353 $record = $records->current(); 1354 while ($record->questionattemptid != $questionattemptid) { 1355 $record = $records->next(); 1356 if (!$records->valid()) { 1357 throw new coding_exception("Question attempt {$questionattemptid} not found in the database."); 1358 } 1359 $record = $records->current(); 1360 } 1361 1362 try { 1363 $question = question_bank::load_question($record->questionid); 1364 } catch (Exception $e) { 1365 // The question must have been deleted somehow. Create a missing 1366 // question to use in its place. 1367 $question = question_bank::get_qtype('missingtype')->make_deleted_instance( 1368 $record->questionid, $record->maxmark + 0); 1369 } 1370 1371 $qa = new question_attempt($question, $record->questionusageid, 1372 null, $record->maxmark + 0); 1373 $qa->set_database_id($record->questionattemptid); 1374 $qa->set_slot($record->slot); 1375 $qa->variant = $record->variant + 0; 1376 $qa->minfraction = $record->minfraction + 0; 1377 $qa->maxfraction = $record->maxfraction + 0; 1378 $qa->set_flagged($record->flagged); 1379 $qa->questionsummary = $record->questionsummary; 1380 $qa->rightanswer = $record->rightanswer; 1381 $qa->responsesummary = $record->responsesummary; 1382 $qa->timemodified = $record->timemodified; 1383 1384 $qa->behaviour = question_engine::make_behaviour( 1385 $record->behaviour, $qa, $preferredbehaviour); 1386 $qa->observer = $observer; 1387 1388 // If attemptstepid is null (which should not happen, but has happened 1389 // due to corrupt data, see MDL-34251) then the current pointer in $records 1390 // will not be advanced in the while loop below, and we get stuck in an 1391 // infinite loop, since this method is supposed to always consume at 1392 // least one record. Therefore, in this case, advance the record here. 1393 if (is_null($record->attemptstepid)) { 1394 $records->next(); 1395 } 1396 1397 $i = 0; 1398 $autosavedstep = null; 1399 $autosavedsequencenumber = null; 1400 while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) { 1401 $sequencenumber = $record->sequencenumber; 1402 $nextstep = question_attempt_step::load_from_records($records, $record->attemptstepid, $qa->get_question()->get_type_name()); 1403 1404 if ($sequencenumber < 0) { 1405 if (!$autosavedstep) { 1406 $autosavedstep = $nextstep; 1407 $autosavedsequencenumber = -$sequencenumber; 1408 } else { 1409 // Old redundant data. Mark it for deletion. 1410 $qa->observer->notify_step_deleted($nextstep, $qa); 1411 } 1412 } else { 1413 $qa->steps[$i] = $nextstep; 1414 if ($i == 0) { 1415 $question->apply_attempt_state($qa->steps[0]); 1416 } 1417 $i++; 1418 } 1419 1420 if ($records->valid()) { 1421 $record = $records->current(); 1422 } else { 1423 $record = false; 1424 } 1425 } 1426 1427 if ($autosavedstep) { 1428 if ($autosavedsequencenumber >= $i) { 1429 $qa->autosavedstep = $autosavedstep; 1430 $qa->steps[$i] = $qa->autosavedstep; 1431 } else { 1432 $qa->observer->notify_step_deleted($autosavedstep, $qa); 1433 } 1434 } 1435 1436 return $qa; 1437 } 1438 1439 /** 1440 * Allow access to steps with responses submitted by students for grading in a question attempt. 1441 * 1442 * @return question_attempt_steps_with_submitted_response_iterator to access all steps with submitted data for questions that 1443 * allow multiple submissions that count towards grade, per attempt. 1444 */ 1445 public function get_steps_with_submitted_response_iterator() { 1446 return new question_attempt_steps_with_submitted_response_iterator($this); 1447 } 1448 } 1449 1450 1451 /** 1452 * This subclass of question_attempt pretends that only part of the step history 1453 * exists. It is used for rendering the question in past states. 1454 * 1455 * All methods that try to modify the question_attempt throw exceptions. 1456 * 1457 * @copyright 2010 The Open University 1458 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1459 */ 1460 class question_attempt_with_restricted_history extends question_attempt { 1461 /** 1462 * @var question_attempt the underlying question_attempt. 1463 */ 1464 protected $baseqa; 1465 1466 /** 1467 * Create a question_attempt_with_restricted_history 1468 * @param question_attempt $baseqa The question_attempt to make a restricted version of. 1469 * @param int $lastseq the index of the last step to include. 1470 * @param string $preferredbehaviour the preferred behaviour. It is slightly 1471 * annoyting that this needs to be passed, but unavoidable for now. 1472 */ 1473 public function __construct(question_attempt $baseqa, $lastseq, $preferredbehaviour) { 1474 $this->baseqa = $baseqa->get_full_qa(); 1475 1476 if ($lastseq < 0 || $lastseq >= $this->baseqa->get_num_steps()) { 1477 throw new coding_exception('$lastseq out of range', $lastseq); 1478 } 1479 1480 $this->steps = array_slice($this->baseqa->steps, 0, $lastseq + 1); 1481 $this->observer = new question_usage_null_observer(); 1482 1483 // This should be a straight copy of all the remaining fields. 1484 $this->id = $this->baseqa->id; 1485 $this->usageid = $this->baseqa->usageid; 1486 $this->slot = $this->baseqa->slot; 1487 $this->question = $this->baseqa->question; 1488 $this->maxmark = $this->baseqa->maxmark; 1489 $this->minfraction = $this->baseqa->minfraction; 1490 $this->maxfraction = $this->baseqa->maxfraction; 1491 $this->questionsummary = $this->baseqa->questionsummary; 1492 $this->responsesummary = $this->baseqa->responsesummary; 1493 $this->rightanswer = $this->baseqa->rightanswer; 1494 $this->flagged = $this->baseqa->flagged; 1495 1496 // Except behaviour, where we need to create a new one. 1497 $this->behaviour = question_engine::make_behaviour( 1498 $this->baseqa->get_behaviour_name(), $this, $preferredbehaviour); 1499 } 1500 1501 public function get_full_qa() { 1502 return $this->baseqa; 1503 } 1504 1505 public function get_full_step_iterator() { 1506 return $this->baseqa->get_step_iterator(); 1507 } 1508 1509 protected function add_step(question_attempt_step $step) { 1510 coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1511 } 1512 public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) { 1513 coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1514 } 1515 public function start($preferredbehaviour, $variant, $submitteddata = array(), $timestamp = null, $userid = null, $existingstepid = null) { 1516 coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1517 } 1518 1519 public function set_database_id($id) { 1520 coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1521 } 1522 public function set_flagged($flagged) { 1523 coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1524 } 1525 public function set_slot($slot) { 1526 coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1527 } 1528 public function set_question_summary($questionsummary) { 1529 coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1530 } 1531 public function set_usage_id($usageid) { 1532 coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1533 } 1534 } 1535 1536 1537 /** 1538 * A class abstracting access to the {@link question_attempt::$states} array. 1539 * 1540 * This is actively linked to question_attempt. If you add an new step 1541 * mid-iteration, then it will be included. 1542 * 1543 * @copyright 2009 The Open University 1544 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1545 */ 1546 class question_attempt_step_iterator implements Iterator, ArrayAccess { 1547 /** @var question_attempt the question_attempt being iterated over. */ 1548 protected $qa; 1549 /** @var integer records the current position in the iteration. */ 1550 protected $i; 1551 1552 /** 1553 * Do not call this constructor directly. 1554 * Use {@link question_attempt::get_step_iterator()}. 1555 * @param question_attempt $qa the attempt to iterate over. 1556 */ 1557 public function __construct(question_attempt $qa) { 1558 $this->qa = $qa; 1559 $this->rewind(); 1560 } 1561 1562 /** @return question_attempt_step */ 1563 public function current() { 1564 return $this->offsetGet($this->i); 1565 } 1566 /** @return int */ 1567 public function key() { 1568 return $this->i; 1569 } 1570 public function next() { 1571 ++$this->i; 1572 } 1573 public function rewind() { 1574 $this->i = 0; 1575 } 1576 /** @return bool */ 1577 public function valid() { 1578 return $this->offsetExists($this->i); 1579 } 1580 1581 /** @return bool */ 1582 public function offsetExists($i) { 1583 return $i >= 0 && $i < $this->qa->get_num_steps(); 1584 } 1585 /** @return question_attempt_step */ 1586 public function offsetGet($i) { 1587 return $this->qa->get_step($i); 1588 } 1589 public function offsetSet($offset, $value) { 1590 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.'); 1591 } 1592 public function offsetUnset($offset) { 1593 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.'); 1594 } 1595 } 1596 1597 1598 /** 1599 * A variant of {@link question_attempt_step_iterator} that iterates through the 1600 * steps in reverse order. 1601 * 1602 * @copyright 2009 The Open University 1603 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1604 */ 1605 class question_attempt_reverse_step_iterator extends question_attempt_step_iterator { 1606 public function next() { 1607 --$this->i; 1608 } 1609 1610 public function rewind() { 1611 $this->i = $this->qa->get_num_steps() - 1; 1612 } 1613 } 1614 1615 /** 1616 * A variant of {@link question_attempt_step_iterator} that iterates through the 1617 * steps with submitted tries. 1618 * 1619 * @copyright 2014 The Open University 1620 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1621 */ 1622 class question_attempt_steps_with_submitted_response_iterator extends question_attempt_step_iterator implements Countable { 1623 1624 /** @var question_attempt the question_attempt being iterated over. */ 1625 protected $qa; 1626 1627 /** @var integer records the current position in the iteration. */ 1628 protected $submittedresponseno; 1629 1630 /** 1631 * Index is the submitted response number and value is the step no. 1632 * 1633 * @var int[] 1634 */ 1635 protected $stepswithsubmittedresponses; 1636 1637 /** 1638 * Do not call this constructor directly. 1639 * Use {@link question_attempt::get_submission_step_iterator()}. 1640 * @param question_attempt $qa the attempt to iterate over. 1641 */ 1642 public function __construct(question_attempt $qa) { 1643 $this->qa = $qa; 1644 $this->find_steps_with_submitted_response(); 1645 $this->rewind(); 1646 } 1647 1648 /** 1649 * Find the step nos in which a student has submitted a response. Including any step with a response that is saved before 1650 * the question attempt finishes. 1651 * 1652 * Called from constructor, should not be called from elsewhere. 1653 * 1654 */ 1655 protected function find_steps_with_submitted_response() { 1656 $stepnos = array(); 1657 $lastsavedstep = null; 1658 foreach ($this->qa->get_step_iterator() as $stepno => $step) { 1659 if ($this->qa->get_behaviour()->step_has_a_submitted_response($step)) { 1660 $stepnos[] = $stepno; 1661 $lastsavedstep = null; 1662 } else { 1663 $qtdata = $step->get_qt_data(); 1664 if (count($qtdata)) { 1665 $lastsavedstep = $stepno; 1666 } 1667 } 1668 } 1669 1670 if (!is_null($lastsavedstep)) { 1671 $stepnos[] = $lastsavedstep; 1672 } 1673 if (empty($stepnos)) { 1674 $this->stepswithsubmittedresponses = array(); 1675 } else { 1676 // Re-index array so index starts with 1. 1677 $this->stepswithsubmittedresponses = array_combine(range(1, count($stepnos)), $stepnos); 1678 } 1679 } 1680 1681 /** @return question_attempt_step */ 1682 public function current() { 1683 return $this->offsetGet($this->submittedresponseno); 1684 } 1685 /** @return int */ 1686 public function key() { 1687 return $this->submittedresponseno; 1688 } 1689 public function next() { 1690 ++$this->submittedresponseno; 1691 } 1692 public function rewind() { 1693 $this->submittedresponseno = 1; 1694 } 1695 /** @return bool */ 1696 public function valid() { 1697 return $this->submittedresponseno >= 1 && $this->submittedresponseno <= count($this->stepswithsubmittedresponses); 1698 } 1699 1700 /** 1701 * @param int $submittedresponseno 1702 * @return bool 1703 */ 1704 public function offsetExists($submittedresponseno) { 1705 return $submittedresponseno >= 1; 1706 } 1707 1708 /** 1709 * @param int $submittedresponseno 1710 * @return question_attempt_step 1711 */ 1712 public function offsetGet($submittedresponseno) { 1713 if ($submittedresponseno > count($this->stepswithsubmittedresponses)) { 1714 return null; 1715 } else { 1716 return $this->qa->get_step($this->step_no_for_try($submittedresponseno)); 1717 } 1718 } 1719 1720 /** 1721 * @return int the count of steps with tries. 1722 */ 1723 public function count() { 1724 return count($this->stepswithsubmittedresponses); 1725 } 1726 1727 /** 1728 * @param int $submittedresponseno 1729 * @throws coding_exception 1730 * @return int|null the step number or null if there is no such submitted response. 1731 */ 1732 public function step_no_for_try($submittedresponseno) { 1733 if (isset($this->stepswithsubmittedresponses[$submittedresponseno])) { 1734 return $this->stepswithsubmittedresponses[$submittedresponseno]; 1735 } else if ($submittedresponseno > count($this->stepswithsubmittedresponses)) { 1736 return null; 1737 } else { 1738 throw new coding_exception('Try number not found. It should be 1 or more.'); 1739 } 1740 } 1741 1742 public function offsetSet($offset, $value) { 1743 throw new coding_exception('You are only allowed read-only access to question_attempt::states '. 1744 'through a question_attempt_step_iterator. Cannot set.'); 1745 } 1746 public function offsetUnset($offset) { 1747 throw new coding_exception('You are only allowed read-only access to question_attempt::states '. 1748 'through a question_attempt_step_iterator. Cannot unset.'); 1749 } 1750 1751 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Fri Nov 28 20:29:05 2014 | Cross-referenced by PHPXref 0.7.1 |