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