Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402] [Versions 402 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 $filearea = question_file_saver::clean_file_area_name('response_' . $name); 626 file_prepare_draft_area($draftid, $contextid, 'question', $filearea, null); 627 return $draftid; 628 } 629 630 /** 631 * Get the latest value of a particular behaviour variable. That is, 632 * get the value from the latest step that has it set. Return null if it is 633 * not set in any step. 634 * 635 * @param string $name the name of the variable to get. 636 * @param mixed default the value to return in the variable has never been set. 637 * (Optional, defaults to null.) 638 * @return mixed string value, or $default if it has never been set. 639 */ 640 public function get_last_behaviour_var($name, $default = null) { 641 foreach ($this->get_reverse_step_iterator() as $step) { 642 if ($step->has_behaviour_var($name)) { 643 return $step->get_behaviour_var($name); 644 } 645 } 646 return $default; 647 } 648 649 /** 650 * Get the current state of this question attempt. That is, the state of the 651 * latest step. 652 * @return question_state 653 */ 654 public function get_state() { 655 return $this->get_last_step()->get_state(); 656 } 657 658 /** 659 * @param bool $showcorrectness Whether right/partial/wrong states should 660 * be distinguised. 661 * @return string A brief textual description of the current state. 662 */ 663 public function get_state_string($showcorrectness) { 664 // Special case when attempt is based on previous one, see MDL-31226. 665 if ($this->get_num_steps() == 1 && $this->get_state() == question_state::$complete) { 666 return get_string('notchanged', 'question'); 667 } 668 return $this->behaviour->get_state_string($showcorrectness); 669 } 670 671 /** 672 * @param bool $showcorrectness Whether right/partial/wrong states should 673 * be distinguised. 674 * @return string a CSS class name for the current state. 675 */ 676 public function get_state_class($showcorrectness) { 677 return $this->get_state()->get_state_class($showcorrectness); 678 } 679 680 /** 681 * @return int the timestamp of the most recent step in this question attempt. 682 */ 683 public function get_last_action_time() { 684 return $this->get_last_step()->get_timecreated(); 685 } 686 687 /** 688 * Get the current fraction of this question attempt. That is, the fraction 689 * of the latest step, or null if this question has not yet been graded. 690 * @return number the current fraction. 691 */ 692 public function get_fraction() { 693 return $this->get_last_step()->get_fraction(); 694 } 695 696 /** @return bool whether this question attempt has a non-zero maximum mark. */ 697 public function has_marks() { 698 // Since grades are stored in the database as NUMBER(12,7). 699 return $this->maxmark >= question_utils::MARK_TOLERANCE; 700 } 701 702 /** 703 * @return number the current mark for this question. 704 * {@link get_fraction()} * {@link get_max_mark()}. 705 */ 706 public function get_mark() { 707 return $this->fraction_to_mark($this->get_fraction()); 708 } 709 710 /** 711 * This is used by the manual grading code, particularly in association with 712 * validation. It gets the current manual mark for a question, in exactly the string 713 * form that the teacher entered it, if possible. This may come from the current 714 * POST request, if there is one, otherwise from the database. 715 * 716 * @return string the current manual mark for this question, in the format the teacher typed, 717 * if possible. 718 */ 719 public function get_current_manual_mark() { 720 // Is there a current value in the current POST data? If so, use that. 721 $mark = $this->get_submitted_var($this->get_behaviour_field_name('mark'), PARAM_RAW_TRIMMED); 722 if ($mark !== null) { 723 return $mark; 724 } 725 726 // Otherwise, use the stored value. 727 // If the question max mark has not changed, use the stored value that was input. 728 $storedmaxmark = $this->get_last_behaviour_var('maxmark'); 729 if ($storedmaxmark !== null && ($storedmaxmark - $this->get_max_mark()) < 0.0000005) { 730 return $this->get_last_behaviour_var('mark'); 731 } 732 733 // The max mark for this question has changed so we must re-scale the current mark. 734 return format_float($this->get_mark(), 7, true, true); 735 } 736 737 /** 738 * @param number|null $fraction a fraction. 739 * @return number|null the corresponding mark. 740 */ 741 public function fraction_to_mark($fraction) { 742 if (is_null($fraction)) { 743 return null; 744 } 745 return $fraction * $this->maxmark; 746 } 747 748 /** 749 * @return float the maximum mark possible for this question attempt. 750 * In fact, this is not strictly the maximum, becuase get_max_fraction may 751 * return a number greater than 1. It might be better to think of this as a 752 * question weight. 753 */ 754 public function get_max_mark() { 755 return $this->maxmark; 756 } 757 758 /** @return float the maximum mark possible for this question attempt. */ 759 public function get_min_fraction() { 760 if (is_null($this->minfraction)) { 761 throw new coding_exception('This question_attempt has not been started yet, the min fraction is not yet known.'); 762 } 763 return $this->minfraction; 764 } 765 766 /** @return float the maximum mark possible for this question attempt. */ 767 public function get_max_fraction() { 768 if (is_null($this->maxfraction)) { 769 throw new coding_exception('This question_attempt has not been started yet, the max fraction is not yet known.'); 770 } 771 return $this->maxfraction; 772 } 773 774 /** 775 * The current mark, formatted to the stated number of decimal places. Uses 776 * {@link format_float()} to format floats according to the current locale. 777 * @param int $dp number of decimal places. 778 * @return string formatted mark. 779 */ 780 public function format_mark($dp) { 781 return $this->format_fraction_as_mark($this->get_fraction(), $dp); 782 } 783 784 /** 785 * The a mark, formatted to the stated number of decimal places. Uses 786 * {@link format_float()} to format floats according to the current locale. 787 * 788 * @param number $fraction a fraction. 789 * @param int $dp number of decimal places. 790 * @return string formatted mark. 791 */ 792 public function format_fraction_as_mark($fraction, $dp) { 793 return format_float($this->fraction_to_mark($fraction), $dp); 794 } 795 796 /** 797 * The maximum mark for this question attempt, formatted to the stated number 798 * of decimal places. Uses {@link format_float()} to format floats according 799 * to the current locale. 800 * @param int $dp number of decimal places. 801 * @return string formatted maximum mark. 802 */ 803 public function format_max_mark($dp) { 804 return format_float($this->maxmark, $dp); 805 } 806 807 /** 808 * Return the hint that applies to the question in its current state, or null. 809 * @return question_hint|null 810 */ 811 public function get_applicable_hint() { 812 return $this->behaviour->get_applicable_hint(); 813 } 814 815 /** 816 * Produce a plain-text summary of what the user did during a step. 817 * @param question_attempt_step $step the step in question. 818 * @return string a summary of what was done during that step. 819 */ 820 public function summarise_action(question_attempt_step $step) { 821 $this->ensure_question_initialised(); 822 return $this->behaviour->summarise_action($step); 823 } 824 825 /** 826 * Return one of the bits of metadata for a this question attempt. 827 * @param string $name the name of the metadata variable to return. 828 * @return string the value of that metadata variable. 829 */ 830 public function get_metadata($name) { 831 return $this->get_step(0)->get_metadata_var($name); 832 } 833 834 /** 835 * Set some metadata for this question attempt. 836 * @param string $name the name of the metadata variable to return. 837 * @param string $value the value to set that metadata variable to. 838 */ 839 public function set_metadata($name, $value) { 840 $firststep = $this->get_step(0); 841 if (!$firststep->has_metadata_var($name)) { 842 $this->observer->notify_metadata_added($this, $name); 843 } else if ($value !== $firststep->get_metadata_var($name)) { 844 $this->observer->notify_metadata_modified($this, $name); 845 } 846 $firststep->set_metadata_var($name, $value); 847 } 848 849 /** 850 * Helper function used by {@link rewrite_pluginfile_urls()} and 851 * {@link rewrite_response_pluginfile_urls()}. 852 * @return array ids that need to go into the file paths. 853 */ 854 protected function extra_file_path_components() { 855 return array($this->get_usage_id(), $this->get_slot()); 856 } 857 858 /** 859 * Calls {@link question_rewrite_question_urls()} with appropriate parameters 860 * for content belonging to this question. 861 * @param string $text the content to output. 862 * @param string $component the component name (normally 'question' or 'qtype_...') 863 * @param string $filearea the name of the file area. 864 * @param int $itemid the item id. 865 * @return string the content with the URLs rewritten. 866 */ 867 public function rewrite_pluginfile_urls($text, $component, $filearea, $itemid) { 868 return question_rewrite_question_urls($text, 'pluginfile.php', 869 $this->question->contextid, $component, $filearea, 870 $this->extra_file_path_components(), $itemid); 871 } 872 873 /** 874 * Calls {@link question_rewrite_question_urls()} with appropriate parameters 875 * for content belonging to responses to this question. 876 * 877 * @param string $text the text to update the URLs in. 878 * @param int $contextid the id of the context the quba belongs to. 879 * @param string $name the variable name the files belong to. 880 * @param question_attempt_step $step the step the response is coming from. 881 * @return string the content with the URLs rewritten. 882 */ 883 public function rewrite_response_pluginfile_urls($text, $contextid, $name, 884 question_attempt_step $step) { 885 return $step->rewrite_response_pluginfile_urls($text, $contextid, $name, 886 $this->extra_file_path_components()); 887 } 888 889 /** 890 * Get the {@link core_question_renderer}, in collaboration with appropriate 891 * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the 892 * HTML to display this question attempt in its current state. 893 * 894 * @param question_display_options $options controls how the question is rendered. 895 * @param string|null $number The question number to display. 896 * @param moodle_page|null $page the page the question is being redered to. 897 * (Optional. Defaults to $PAGE.) 898 * @return string HTML fragment representing the question. 899 */ 900 public function render($options, $number, $page = null) { 901 $this->ensure_question_initialised(); 902 if (is_null($page)) { 903 global $PAGE; 904 $page = $PAGE; 905 } 906 $qoutput = $page->get_renderer('core', 'question'); 907 $qtoutput = $this->question->get_renderer($page); 908 return $this->behaviour->render($options, $number, $qoutput, $qtoutput); 909 } 910 911 /** 912 * Generate any bits of HTML that needs to go in the <head> tag when this question 913 * attempt is displayed in the body. 914 * @return string HTML fragment. 915 */ 916 public function render_head_html($page = null) { 917 $this->ensure_question_initialised(); 918 if (is_null($page)) { 919 global $PAGE; 920 $page = $PAGE; 921 } 922 // TODO go via behaviour. 923 return $this->question->get_renderer($page)->head_code($this) . 924 $this->behaviour->get_renderer($page)->head_code($this); 925 } 926 927 /** 928 * Like {@link render_question()} but displays the question at the past step 929 * indicated by $seq, rather than showing the latest step. 930 * 931 * @param int $seq the seq number of the past state to display. 932 * @param question_display_options $options controls how the question is rendered. 933 * @param string|null $number The question number to display. 'i' is a special 934 * value that gets displayed as Information. Null means no number is displayed. 935 * @param string $preferredbehaviour the preferred behaviour. It is slightly 936 * annoying that this needs to be passed, but unavoidable for now. 937 * @return string HTML fragment representing the question. 938 */ 939 public function render_at_step($seq, $options, $number, $preferredbehaviour) { 940 $this->ensure_question_initialised(); 941 $restrictedqa = new question_attempt_with_restricted_history($this, $seq, $preferredbehaviour); 942 return $restrictedqa->render($options, $number); 943 } 944 945 /** 946 * Checks whether the users is allow to be served a particular file. 947 * @param question_display_options $options the options that control display of the question. 948 * @param string $component the name of the component we are serving files for. 949 * @param string $filearea the name of the file area. 950 * @param array $args the remaining bits of the file path. 951 * @param bool $forcedownload whether the user must be forced to download the file. 952 * @return bool true if the user can access this file. 953 */ 954 public function check_file_access($options, $component, $filearea, $args, $forcedownload) { 955 $this->ensure_question_initialised(); 956 return $this->behaviour->check_file_access($options, $component, $filearea, $args, $forcedownload); 957 } 958 959 /** 960 * Add a step to this question attempt. 961 * @param question_attempt_step $step the new step. 962 */ 963 protected function add_step(question_attempt_step $step) { 964 $this->steps[] = $step; 965 end($this->steps); 966 $this->observer->notify_step_added($step, $this, key($this->steps)); 967 } 968 969 /** 970 * Add an auto-saved step to this question attempt. We mark auto-saved steps by 971 * changing saving the step number with a - sign. 972 * @param question_attempt_step $step the new step. 973 */ 974 protected function add_autosaved_step(question_attempt_step $step) { 975 $this->steps[] = $step; 976 $this->autosavedstep = $step; 977 end($this->steps); 978 $this->observer->notify_step_added($step, $this, -key($this->steps)); 979 } 980 981 /** 982 * Discard any auto-saved data belonging to this question attempt. 983 */ 984 public function discard_autosaved_step() { 985 if (!$this->has_autosaved_step()) { 986 return; 987 } 988 989 $autosaved = array_pop($this->steps); 990 $this->autosavedstep = null; 991 $this->observer->notify_step_deleted($autosaved, $this); 992 } 993 994 /** 995 * If there is an autosaved step, convert it into a real save, so that it 996 * is preserved. 997 */ 998 protected function convert_autosaved_step_to_real_step() { 999 if ($this->autosavedstep === null) { 1000 return; 1001 } 1002 1003 $laststep = end($this->steps); 1004 if ($laststep !== $this->autosavedstep) { 1005 throw new coding_exception('Cannot convert autosaved step to real step, since other steps have been added.'); 1006 } 1007 1008 $this->observer->notify_step_modified($this->autosavedstep, $this, key($this->steps)); 1009 $this->autosavedstep = null; 1010 } 1011 1012 /** 1013 * Use a strategy to pick a variant. 1014 * @param question_variant_selection_strategy $variantstrategy a strategy. 1015 * @return int the selected variant. 1016 */ 1017 public function select_variant(question_variant_selection_strategy $variantstrategy) { 1018 return $variantstrategy->choose_variant($this->get_question()->get_num_variants(), 1019 $this->get_question()->get_variants_selection_seed()); 1020 } 1021 1022 /** 1023 * Start this question attempt. 1024 * 1025 * You should not call this method directly. Call 1026 * {@link question_usage_by_activity::start_question()} instead. 1027 * 1028 * @param string|question_behaviour $preferredbehaviour the name of the 1029 * desired archetypal behaviour, or an actual behaviour instance. 1030 * @param int $variant the variant of the question to start. Between 1 and 1031 * $this->get_question()->get_num_variants() inclusive. 1032 * @param array $submitteddata optional, used when re-starting to keep the same initial state. 1033 * @param int $timestamp optional, the timstamp to record for this action. Defaults to now. 1034 * @param int $userid optional, the user to attribute this action to. Defaults to the current user. 1035 * @param int $existingstepid optional, if this step is going to replace an existing step 1036 * (for example, during a regrade) this is the id of the previous step we are replacing. 1037 */ 1038 public function start($preferredbehaviour, $variant, $submitteddata = array(), 1039 $timestamp = null, $userid = null, $existingstepid = null) { 1040 1041 if ($this->get_num_steps() > 0) { 1042 throw new coding_exception('Cannot start a question that is already started.'); 1043 } 1044 1045 // Initialise the behaviour. 1046 $this->variant = $variant; 1047 if (is_string($preferredbehaviour)) { 1048 $this->behaviour = 1049 $this->question->make_behaviour($this, $preferredbehaviour); 1050 } else { 1051 $class = get_class($preferredbehaviour); 1052 $this->behaviour = new $class($this, $preferredbehaviour); 1053 } 1054 1055 // Record the minimum and maximum fractions. 1056 $this->minfraction = $this->behaviour->get_min_fraction(); 1057 $this->maxfraction = $this->behaviour->get_max_fraction(); 1058 1059 // Initialise the first step. 1060 $firststep = new question_attempt_step($submitteddata, $timestamp, $userid, $existingstepid); 1061 if ($submitteddata) { 1062 $firststep->set_state(question_state::$complete); 1063 $this->behaviour->apply_attempt_state($firststep); 1064 } else { 1065 $this->behaviour->init_first_step($firststep, $variant); 1066 } 1067 $this->questioninitialised = self::QUESTION_STATE_APPLIED; 1068 $this->add_step($firststep); 1069 1070 // Record questionline and correct answer. 1071 $this->questionsummary = $this->behaviour->get_question_summary(); 1072 $this->rightanswer = $this->behaviour->get_right_answer_summary(); 1073 } 1074 1075 /** 1076 * Start this question attempt, starting from the point that the previous 1077 * attempt $oldqa had reached. 1078 * 1079 * You should not call this method directly. Call 1080 * {@link question_usage_by_activity::start_question_based_on()} instead. 1081 * 1082 * @param question_attempt $oldqa a previous attempt at this quetsion that 1083 * defines the starting point. 1084 */ 1085 public function start_based_on(question_attempt $oldqa) { 1086 $this->start($oldqa->behaviour, $oldqa->get_variant(), $oldqa->get_resume_data()); 1087 } 1088 1089 /** 1090 * Used by {@link start_based_on()} to get the data needed to start a new 1091 * attempt from the point this attempt has go to. 1092 * @return array name => value pairs. 1093 */ 1094 protected function get_resume_data() { 1095 $this->ensure_question_initialised(); 1096 $resumedata = $this->behaviour->get_resume_data(); 1097 foreach ($resumedata as $name => $value) { 1098 if ($value instanceof question_file_loader) { 1099 $resumedata[$name] = $value->get_question_file_saver(); 1100 } 1101 } 1102 return $resumedata; 1103 } 1104 1105 /** 1106 * Get a particular parameter from the current request. A wrapper round 1107 * {@link optional_param()}, except that the results is returned without 1108 * slashes. 1109 * @param string $name the paramter name. 1110 * @param int $type one of the standard PARAM_... constants, or one of the 1111 * special extra constands defined by this class. 1112 * @param array $postdata (optional, only inteded for testing use) take the 1113 * data from this array, instead of from $_POST. 1114 * @return mixed the requested value. 1115 */ 1116 public function get_submitted_var($name, $type, $postdata = null) { 1117 switch ($type) { 1118 1119 case self::PARAM_FILES: 1120 return $this->process_response_files($name, $name, $postdata); 1121 1122 case self::PARAM_RAW_FILES: 1123 $var = $this->get_submitted_var($name, PARAM_RAW, $postdata); 1124 return $this->process_response_files($name, $name . ':itemid', $postdata, $var); 1125 1126 default: 1127 if (is_null($postdata)) { 1128 $var = optional_param($name, null, $type); 1129 } else if (array_key_exists($name, $postdata)) { 1130 $var = clean_param($postdata[$name], $type); 1131 } else { 1132 $var = null; 1133 } 1134 1135 if ($var !== null) { 1136 // Ensure that, if set, $var is a string. This is because later, after 1137 // it has been saved to the database and loaded back it will be a string, 1138 // so better if the type is predictably always a string. 1139 $var = (string) $var; 1140 } 1141 1142 return $var; 1143 } 1144 } 1145 1146 /** 1147 * Validate the manual mark for a question. 1148 * @param string $currentmark the user input (e.g. '1,0', '1,0' or 'invalid'. 1149 * @return string any errors with the value, or '' if it is OK. 1150 */ 1151 public function validate_manual_mark($currentmark) { 1152 if ($currentmark === null || $currentmark === '') { 1153 return ''; 1154 } 1155 1156 $mark = question_utils::clean_param_mark($currentmark); 1157 if ($mark === null) { 1158 return get_string('manualgradeinvalidformat', 'question'); 1159 } 1160 1161 $maxmark = $this->get_max_mark(); 1162 if ($mark > $maxmark * $this->get_max_fraction() + question_utils::MARK_TOLERANCE || 1163 $mark < $maxmark * $this->get_min_fraction() - question_utils::MARK_TOLERANCE) { 1164 return get_string('manualgradeoutofrange', 'question'); 1165 } 1166 1167 return ''; 1168 } 1169 1170 /** 1171 * Handle a submitted variable representing uploaded files. 1172 * @param string $name the field name. 1173 * @param string $draftidname the field name holding the draft file area id. 1174 * @param array $postdata (optional, only inteded for testing use) take the 1175 * data from this array, instead of from $_POST. At the moment, this 1176 * behaves as if there were no files. 1177 * @param string $text optional reponse text. 1178 * @return question_file_saver that can be used to save the files later. 1179 */ 1180 protected function process_response_files($name, $draftidname, $postdata = null, $text = null) { 1181 if ($postdata) { 1182 // For simulated posts, get the draft itemid from there. 1183 $draftitemid = $this->get_submitted_var($draftidname, PARAM_INT, $postdata); 1184 } else { 1185 $draftitemid = file_get_submitted_draft_itemid($draftidname); 1186 } 1187 1188 if (!$draftitemid) { 1189 return null; 1190 } 1191 1192 $filearea = str_replace($this->get_field_prefix(), '', $name); 1193 $filearea = str_replace('-', 'bf_', $filearea); 1194 $filearea = 'response_' . $filearea; 1195 return new question_file_saver($draftitemid, 'question', $filearea, $text); 1196 } 1197 1198 /** 1199 * Get any data from the request that matches the list of expected params. 1200 * 1201 * @param array $expected variable name => PARAM_... constant. 1202 * @param null|array $postdata null to use real post data, otherwise an array of data to use. 1203 * @param string $extraprefix '-' or ''. 1204 * @return array name => value. 1205 */ 1206 protected function get_expected_data($expected, $postdata, $extraprefix) { 1207 $submitteddata = array(); 1208 foreach ($expected as $name => $type) { 1209 $value = $this->get_submitted_var( 1210 $this->get_field_prefix() . $extraprefix . $name, $type, $postdata); 1211 if (!is_null($value)) { 1212 $submitteddata[$extraprefix . $name] = $value; 1213 } 1214 } 1215 return $submitteddata; 1216 } 1217 1218 /** 1219 * Get all the submitted question type data for this question, whithout checking 1220 * that it is valid or cleaning it in any way. 1221 * 1222 * @param null|array $postdata null to use real post data, otherwise an array of data to use. 1223 * @return array name => value. 1224 */ 1225 public function get_all_submitted_qt_vars($postdata) { 1226 if (is_null($postdata)) { 1227 $postdata = $_POST; 1228 } 1229 1230 $pattern = '/^' . preg_quote($this->get_field_prefix(), '/') . '[^-:]/'; 1231 $prefixlen = strlen($this->get_field_prefix()); 1232 1233 $submitteddata = array(); 1234 foreach ($postdata as $name => $value) { 1235 if (preg_match($pattern, $name)) { 1236 $submitteddata[substr($name, $prefixlen)] = $value; 1237 } 1238 } 1239 1240 return $submitteddata; 1241 } 1242 1243 /** 1244 * Get all the sumbitted data belonging to this question attempt from the 1245 * current request. 1246 * @param array $postdata (optional, only inteded for testing use) take the 1247 * data from this array, instead of from $_POST. 1248 * @return array name => value pairs that could be passed to {@link process_action()}. 1249 */ 1250 public function get_submitted_data($postdata = null) { 1251 $this->ensure_question_initialised(); 1252 1253 $submitteddata = $this->get_expected_data( 1254 $this->behaviour->get_expected_data(), $postdata, '-'); 1255 1256 $expected = $this->behaviour->get_expected_qt_data(); 1257 $this->check_qt_var_name_restrictions($expected); 1258 1259 if ($expected === self::USE_RAW_DATA) { 1260 $submitteddata += $this->get_all_submitted_qt_vars($postdata); 1261 } else { 1262 $submitteddata += $this->get_expected_data($expected, $postdata, ''); 1263 } 1264 return $submitteddata; 1265 } 1266 1267 /** 1268 * Ensure that no reserved prefixes are being used by installed 1269 * question types. 1270 * @param array $expected An array of question type variables 1271 */ 1272 protected function check_qt_var_name_restrictions($expected) { 1273 global $CFG; 1274 1275 if ($CFG->debugdeveloper && $expected !== self::USE_RAW_DATA) { 1276 foreach ($expected as $key => $value) { 1277 if (strpos($key, 'bf_') !== false) { 1278 debugging('The bf_ prefix is reserved and cannot be used by question types', DEBUG_DEVELOPER); 1279 } 1280 } 1281 } 1282 } 1283 1284 /** 1285 * Get a set of response data for this question attempt that would get the 1286 * best possible mark. If it is not possible to compute a correct 1287 * response, this method should return null. 1288 * @return array|null name => value pairs that could be passed to {@link process_action()}. 1289 */ 1290 public function get_correct_response() { 1291 $this->ensure_question_initialised(); 1292 $response = $this->question->get_correct_response(); 1293 if (is_null($response)) { 1294 return null; 1295 } 1296 $imvars = $this->behaviour->get_correct_response(); 1297 foreach ($imvars as $name => $value) { 1298 $response['-' . $name] = $value; 1299 } 1300 return $response; 1301 } 1302 1303 /** 1304 * Change the quetsion summary. Note, that this is almost never necessary. 1305 * This method was only added to work around a limitation of the Opaque 1306 * protocol, which only sends questionLine at the end of an attempt. 1307 * @param string $questionsummary the new summary to set. 1308 */ 1309 public function set_question_summary($questionsummary) { 1310 $this->questionsummary = $questionsummary; 1311 $this->observer->notify_attempt_modified($this); 1312 } 1313 1314 /** 1315 * @return string a simple textual summary of the question that was asked. 1316 */ 1317 public function get_question_summary() { 1318 return $this->questionsummary; 1319 } 1320 1321 /** 1322 * @return string a simple textual summary of response given. 1323 */ 1324 public function get_response_summary() { 1325 return $this->responsesummary; 1326 } 1327 1328 /** 1329 * @return string a simple textual summary of the correct resonse. 1330 */ 1331 public function get_right_answer_summary() { 1332 return $this->rightanswer; 1333 } 1334 1335 /** 1336 * Whether this attempt at this question could be completed just by the 1337 * student interacting with the question, before {@link finish()} is called. 1338 * 1339 * @return boolean whether this attempt can finish naturally. 1340 */ 1341 public function can_finish_during_attempt() { 1342 $this->ensure_question_initialised(); 1343 return $this->behaviour->can_finish_during_attempt(); 1344 } 1345 1346 /** 1347 * Perform the action described by $submitteddata. 1348 * @param array $submitteddata the submitted data the determines the action. 1349 * @param int $timestamp the time to record for the action. (If not given, use now.) 1350 * @param int $userid the user to attribute the action to. (If not given, use the current user.) 1351 * @param int $existingstepid used by the regrade code. 1352 */ 1353 public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) { 1354 $this->ensure_question_initialised(); 1355 $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid, $existingstepid); 1356 $this->discard_autosaved_step(); 1357 if ($this->behaviour->process_action($pendingstep) == self::KEEP) { 1358 $this->add_step($pendingstep); 1359 if ($pendingstep->response_summary_changed()) { 1360 $this->responsesummary = $pendingstep->get_new_response_summary(); 1361 } 1362 if ($pendingstep->variant_number_changed()) { 1363 $this->variant = $pendingstep->get_new_variant_number(); 1364 } 1365 } 1366 } 1367 1368 /** 1369 * Process an autosave. 1370 * @param array $submitteddata the submitted data the determines the action. 1371 * @param int $timestamp the time to record for the action. (If not given, use now.) 1372 * @param int $userid the user to attribute the action to. (If not given, use the current user.) 1373 * @return bool whether anything was saved. 1374 */ 1375 public function process_autosave($submitteddata, $timestamp = null, $userid = null) { 1376 $this->ensure_question_initialised(); 1377 $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid); 1378 if ($this->behaviour->process_autosave($pendingstep) == self::KEEP) { 1379 $this->add_autosaved_step($pendingstep); 1380 return true; 1381 } 1382 return false; 1383 } 1384 1385 /** 1386 * Perform a finish action on this question attempt. This corresponds to an 1387 * external finish action, for example the user pressing Submit all and finish 1388 * in the quiz, rather than using one of the controls that is part of the 1389 * question. 1390 * 1391 * @param int $timestamp the time to record for the action. (If not given, use now.) 1392 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.) 1393 */ 1394 public function finish($timestamp = null, $userid = null) { 1395 $this->ensure_question_initialised(); 1396 $this->convert_autosaved_step_to_real_step(); 1397 $this->process_action(array('-finish' => 1), $timestamp, $userid); 1398 } 1399 1400 /** 1401 * Verify if this question_attempt in can be regraded with that other question version. 1402 * 1403 * @param question_definition $otherversion a different version of the question to use in the regrade. 1404 * @return string|null null if the regrade can proceed, else a reason why not. 1405 */ 1406 public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string { 1407 return $this->get_question(false)->validate_can_regrade_with_other_version($otherversion); 1408 } 1409 1410 /** 1411 * Perform a regrade. This replays all the actions from $oldqa into this 1412 * attempt. 1413 * @param question_attempt $oldqa the attempt to regrade. 1414 * @param bool $finished whether the question attempt should be forced to be finished 1415 * after the regrade, or whether it may still be in progress (default false). 1416 */ 1417 public function regrade(question_attempt $oldqa, $finished) { 1418 $oldqa->ensure_question_initialised(); 1419 $first = true; 1420 foreach ($oldqa->get_step_iterator() as $step) { 1421 $this->observer->notify_step_deleted($step, $this); 1422 1423 if ($first) { 1424 // First step of the attempt. 1425 $first = false; 1426 $this->start($oldqa->behaviour, $oldqa->get_variant(), 1427 $this->get_attempt_state_data_to_regrade_with_version($step, $oldqa->get_question()), 1428 $step->get_timecreated(), $step->get_user_id(), $step->get_id()); 1429 1430 } else if ($step->has_behaviour_var('finish') && count($step->get_submitted_data()) > 1) { 1431 // This case relates to MDL-32062. The upgrade code from 2.0 1432 // generates attempts where the final submit of the question 1433 // data, and the finish action, are in the same step. The system 1434 // cannot cope with that, so convert the single old step into 1435 // two new steps. 1436 $submitteddata = $step->get_submitted_data(); 1437 unset($submitteddata['-finish']); 1438 $this->process_action($submitteddata, 1439 $step->get_timecreated(), $step->get_user_id(), $step->get_id()); 1440 $this->finish($step->get_timecreated(), $step->get_user_id()); 1441 1442 } else { 1443 // This is the normal case. Replay the next step of the attempt. 1444 if ($step === $oldqa->autosavedstep) { 1445 $this->process_autosave($step->get_submitted_data(), 1446 $step->get_timecreated(), $step->get_user_id()); 1447 } else { 1448 $this->process_action($step->get_submitted_data(), 1449 $step->get_timecreated(), $step->get_user_id(), $step->get_id()); 1450 } 1451 } 1452 } 1453 1454 if ($finished) { 1455 $this->finish(); 1456 } 1457 1458 $this->set_flagged($oldqa->is_flagged()); 1459 } 1460 1461 /** 1462 * Helper used by regrading. 1463 * 1464 * Get the data from the first step of the old attempt and, if necessary, 1465 * update it to be suitable for use with the other version of the question. 1466 * 1467 * @param question_attempt_step $oldstep First step at an attempt at $otherversion of this question. 1468 * @param question_definition $otherversion Another version of the question being attempted. 1469 * @return array updated data required to restart an attempt with the current version of this question. 1470 */ 1471 protected function get_attempt_state_data_to_regrade_with_version(question_attempt_step $oldstep, 1472 question_definition $otherversion): array { 1473 if ($this->get_question(false) === $otherversion) { 1474 return $oldstep->get_all_data(); 1475 } else { 1476 // Update the data belonging to the question type by asking the question to do it. 1477 $attemptstatedata = $this->get_question(false)->update_attempt_state_data_for_new_version( 1478 $oldstep, $otherversion); 1479 1480 // Then copy over all the behaviour and metadata variables. 1481 // This terminology is explained in the class comment on {@see question_attempt_step}. 1482 foreach ($oldstep->get_all_data() as $name => $value) { 1483 if (substr($name, 0, 1) === '-' || substr($name, 0, 2) === ':_') { 1484 $attemptstatedata[$name] = $value; 1485 } 1486 } 1487 return $attemptstatedata; 1488 } 1489 } 1490 1491 /** 1492 * Change the max mark for this question_attempt. 1493 * @param float $maxmark the new max mark. 1494 */ 1495 public function set_max_mark($maxmark) { 1496 $this->maxmark = $maxmark; 1497 $this->observer->notify_attempt_modified($this); 1498 } 1499 1500 /** 1501 * Perform a manual grading action on this attempt. 1502 * @param string $comment the comment being added. 1503 * @param float $mark the new mark. If null, then only a comment is added. 1504 * @param int $commentformat the FORMAT_... for $comment. Must be given. 1505 * @param int $timestamp the time to record for the action. (If not given, use now.) 1506 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.) 1507 */ 1508 public function manual_grade($comment, $mark, $commentformat = null, $timestamp = null, $userid = null) { 1509 $this->ensure_question_initialised(); 1510 $submitteddata = array('-comment' => $comment); 1511 if (is_null($commentformat)) { 1512 debugging('You should pass $commentformat to manual_grade.', DEBUG_DEVELOPER); 1513 $commentformat = FORMAT_HTML; 1514 } 1515 $submitteddata['-commentformat'] = $commentformat; 1516 if (!is_null($mark)) { 1517 $submitteddata['-mark'] = $mark; 1518 $submitteddata['-maxmark'] = $this->maxmark; 1519 } 1520 $this->process_action($submitteddata, $timestamp, $userid); 1521 } 1522 1523 /** @return bool Whether this question attempt has had a manual comment added. */ 1524 public function has_manual_comment() { 1525 foreach ($this->steps as $step) { 1526 if ($step->has_behaviour_var('comment')) { 1527 return true; 1528 } 1529 } 1530 return false; 1531 } 1532 1533 /** 1534 * @return array(string, int) the most recent manual comment that was added 1535 * to this question, the FORMAT_... it is and the step itself. 1536 */ 1537 public function get_manual_comment() { 1538 foreach ($this->get_reverse_step_iterator() as $step) { 1539 if ($step->has_behaviour_var('comment')) { 1540 return array($step->get_behaviour_var('comment'), 1541 $step->get_behaviour_var('commentformat'), 1542 $step); 1543 } 1544 } 1545 return array(null, null, null); 1546 } 1547 1548 /** 1549 * This is used by the manual grading code, particularly in association with 1550 * validation. If there is a comment submitted in the request, then use that, 1551 * otherwise use the latest comment for this question. 1552 * 1553 * @return array with three elements, comment, commentformat and mark. 1554 */ 1555 public function get_current_manual_comment() { 1556 $comment = $this->get_submitted_var($this->get_behaviour_field_name('comment'), PARAM_RAW); 1557 if (is_null($comment)) { 1558 return $this->get_manual_comment(); 1559 } else { 1560 $commentformat = $this->get_submitted_var( 1561 $this->get_behaviour_field_name('commentformat'), PARAM_INT); 1562 if ($commentformat === null) { 1563 $commentformat = FORMAT_HTML; 1564 } 1565 return array($comment, $commentformat, null); 1566 } 1567 } 1568 1569 /** 1570 * Break down a student response by sub part and classification. See also {@link question::classify_response}. 1571 * Used for response analysis. 1572 * 1573 * @param string $whichtries which tries to analyse for response analysis. Will be one of 1574 * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. Defaults to question_attempt::LAST_TRY. 1575 * @return question_classified_response[]|question_classified_response[][] If $whichtries is 1576 * question_attempt::FIRST_TRY or LAST_TRY index is subpartid and values are 1577 * question_classified_response instances. 1578 * If $whichtries is question_attempt::ALL_TRIES then first key is submitted response no 1579 * and the second key is subpartid. 1580 */ 1581 public function classify_response($whichtries = self::LAST_TRY) { 1582 $this->ensure_question_initialised(); 1583 return $this->behaviour->classify_response($whichtries); 1584 } 1585 1586 /** 1587 * Create a question_attempt_step from records loaded from the database. 1588 * 1589 * For internal use only. 1590 * 1591 * @param Iterator $records Raw records loaded from the database. 1592 * @param int $questionattemptid The id of the question_attempt to extract. 1593 * @param question_usage_observer $observer the observer that will be monitoring changes in us. 1594 * @param string $preferredbehaviour the preferred behaviour under which we are operating. 1595 * @return question_attempt The newly constructed question_attempt. 1596 */ 1597 public static function load_from_records($records, $questionattemptid, 1598 question_usage_observer $observer, $preferredbehaviour) { 1599 $record = $records->current(); 1600 while ($record->questionattemptid != $questionattemptid) { 1601 $records->next(); 1602 if (!$records->valid()) { 1603 throw new coding_exception("Question attempt {$questionattemptid} not found in the database."); 1604 } 1605 $record = $records->current(); 1606 } 1607 1608 try { 1609 $question = question_bank::load_question($record->questionid); 1610 } catch (Exception $e) { 1611 // The question must have been deleted somehow. Create a missing 1612 // question to use in its place. 1613 $question = question_bank::get_qtype('missingtype')->make_deleted_instance( 1614 $record->questionid, $record->maxmark + 0); 1615 } 1616 1617 $qa = new question_attempt($question, $record->questionusageid, 1618 null, $record->maxmark + 0); 1619 $qa->set_database_id($record->questionattemptid); 1620 $qa->set_slot($record->slot); 1621 $qa->variant = $record->variant + 0; 1622 $qa->minfraction = $record->minfraction + 0; 1623 $qa->maxfraction = $record->maxfraction + 0; 1624 $qa->set_flagged($record->flagged); 1625 $qa->questionsummary = $record->questionsummary; 1626 $qa->rightanswer = $record->rightanswer; 1627 $qa->responsesummary = $record->responsesummary; 1628 $qa->timemodified = $record->timemodified; 1629 1630 $qa->behaviour = question_engine::make_behaviour( 1631 $record->behaviour, $qa, $preferredbehaviour); 1632 $qa->observer = $observer; 1633 1634 // If attemptstepid is null (which should not happen, but has happened 1635 // due to corrupt data, see MDL-34251) then the current pointer in $records 1636 // will not be advanced in the while loop below, and we get stuck in an 1637 // infinite loop, since this method is supposed to always consume at 1638 // least one record. Therefore, in this case, advance the record here. 1639 if (is_null($record->attemptstepid)) { 1640 $records->next(); 1641 } 1642 1643 $i = 0; 1644 $autosavedstep = null; 1645 $autosavedsequencenumber = null; 1646 while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) { 1647 $sequencenumber = $record->sequencenumber; 1648 $nextstep = question_attempt_step::load_from_records($records, $record->attemptstepid, 1649 $qa->get_question(false)->get_type_name()); 1650 1651 if ($sequencenumber < 0) { 1652 if (!$autosavedstep) { 1653 $autosavedstep = $nextstep; 1654 $autosavedsequencenumber = -$sequencenumber; 1655 } else { 1656 // Old redundant data. Mark it for deletion. 1657 $qa->observer->notify_step_deleted($nextstep, $qa); 1658 } 1659 } else { 1660 $qa->steps[$i] = $nextstep; 1661 $i++; 1662 } 1663 1664 if ($records->valid()) { 1665 $record = $records->current(); 1666 } else { 1667 $record = false; 1668 } 1669 } 1670 1671 if ($autosavedstep) { 1672 if ($autosavedsequencenumber >= $i) { 1673 $qa->autosavedstep = $autosavedstep; 1674 $qa->steps[$i] = $qa->autosavedstep; 1675 } else { 1676 $qa->observer->notify_step_deleted($autosavedstep, $qa); 1677 } 1678 } 1679 1680 return $qa; 1681 } 1682 1683 /** 1684 * This method is part of the lazy-initialisation of question objects. 1685 * 1686 * Methods which require $this->question to be fully initialised 1687 * (to have had init_first_step or apply_attempt_state called on it) 1688 * should call this method before proceeding. 1689 */ 1690 protected function ensure_question_initialised() { 1691 if ($this->questioninitialised === self::QUESTION_STATE_APPLIED) { 1692 return; // Already done. 1693 } 1694 1695 if (empty($this->steps)) { 1696 throw new coding_exception('You must call start() before doing anything to a question_attempt().'); 1697 } 1698 1699 $this->question->apply_attempt_state($this->steps[0]); 1700 $this->questioninitialised = self::QUESTION_STATE_APPLIED; 1701 } 1702 1703 /** 1704 * Allow access to steps with responses submitted by students for grading in a question attempt. 1705 * 1706 * @return question_attempt_steps_with_submitted_response_iterator to access all steps with submitted data for questions that 1707 * allow multiple submissions that count towards grade, per attempt. 1708 */ 1709 public function get_steps_with_submitted_response_iterator() { 1710 return new question_attempt_steps_with_submitted_response_iterator($this); 1711 } 1712 } 1713 1714 1715 /** 1716 * This subclass of question_attempt pretends that only part of the step history 1717 * exists. It is used for rendering the question in past states. 1718 * 1719 * All methods that try to modify the question_attempt throw exceptions. 1720 * 1721 * @copyright 2010 The Open University 1722 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1723 */ 1724 class question_attempt_with_restricted_history extends question_attempt { 1725 /** 1726 * @var question_attempt the underlying question_attempt. 1727 */ 1728 protected $baseqa; 1729 1730 /** 1731 * Create a question_attempt_with_restricted_history 1732 * @param question_attempt $baseqa The question_attempt to make a restricted version of. 1733 * @param int $lastseq the index of the last step to include. 1734 * @param string $preferredbehaviour the preferred behaviour. It is slightly 1735 * annoying that this needs to be passed, but unavoidable for now. 1736 */ 1737 public function __construct(question_attempt $baseqa, $lastseq, $preferredbehaviour) { 1738 $this->baseqa = $baseqa->get_full_qa(); 1739 1740 if ($lastseq < 0 || $lastseq >= $this->baseqa->get_num_steps()) { 1741 throw new coding_exception('$lastseq out of range', $lastseq); 1742 } 1743 1744 $this->steps = array_slice($this->baseqa->steps, 0, $lastseq + 1); 1745 $this->observer = new question_usage_null_observer(); 1746 1747 // This should be a straight copy of all the remaining fields. 1748 $this->id = $this->baseqa->id; 1749 $this->usageid = $this->baseqa->usageid; 1750 $this->slot = $this->baseqa->slot; 1751 $this->question = $this->baseqa->question; 1752 $this->maxmark = $this->baseqa->maxmark; 1753 $this->minfraction = $this->baseqa->minfraction; 1754 $this->maxfraction = $this->baseqa->maxfraction; 1755 $this->questionsummary = $this->baseqa->questionsummary; 1756 $this->responsesummary = $this->baseqa->responsesummary; 1757 $this->rightanswer = $this->baseqa->rightanswer; 1758 $this->flagged = $this->baseqa->flagged; 1759 1760 // Except behaviour, where we need to create a new one. 1761 $this->behaviour = question_engine::make_behaviour( 1762 $this->baseqa->get_behaviour_name(), $this, $preferredbehaviour); 1763 } 1764 1765 public function get_full_qa() { 1766 return $this->baseqa; 1767 } 1768 1769 public function get_full_step_iterator() { 1770 return $this->baseqa->get_step_iterator(); 1771 } 1772 1773 protected function add_step(question_attempt_step $step) { 1774 throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1775 } 1776 public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) { 1777 throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1778 } 1779 public function start($preferredbehaviour, $variant, $submitteddata = array(), $timestamp = null, $userid = null, $existingstepid = null) { 1780 throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1781 } 1782 1783 public function set_database_id($id) { 1784 throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1785 } 1786 public function set_flagged($flagged) { 1787 throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1788 } 1789 public function set_slot($slot) { 1790 throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1791 } 1792 public function set_question_summary($questionsummary) { 1793 throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1794 } 1795 public function set_usage_id($usageid) { 1796 throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.'); 1797 } 1798 } 1799 1800 1801 /** 1802 * A class abstracting access to the {@link question_attempt::$states} array. 1803 * 1804 * This is actively linked to question_attempt. If you add an new step 1805 * mid-iteration, then it will be included. 1806 * 1807 * @copyright 2009 The Open University 1808 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1809 */ 1810 class question_attempt_step_iterator implements Iterator, ArrayAccess { 1811 /** @var question_attempt the question_attempt being iterated over. */ 1812 protected $qa; 1813 /** @var integer records the current position in the iteration. */ 1814 protected $i; 1815 1816 /** 1817 * Do not call this constructor directly. 1818 * Use {@link question_attempt::get_step_iterator()}. 1819 * @param question_attempt $qa the attempt to iterate over. 1820 */ 1821 public function __construct(question_attempt $qa) { 1822 $this->qa = $qa; 1823 $this->rewind(); 1824 } 1825 1826 /** @return question_attempt_step */ 1827 #[\ReturnTypeWillChange] 1828 public function current() { 1829 return $this->offsetGet($this->i); 1830 } 1831 /** @return int */ 1832 #[\ReturnTypeWillChange] 1833 public function key() { 1834 return $this->i; 1835 } 1836 public function next(): void { 1837 ++$this->i; 1838 } 1839 public function rewind(): void { 1840 $this->i = 0; 1841 } 1842 /** @return bool */ 1843 public function valid(): bool { 1844 return $this->offsetExists($this->i); 1845 } 1846 1847 /** @return bool */ 1848 public function offsetExists($i): bool { 1849 return $i >= 0 && $i < $this->qa->get_num_steps(); 1850 } 1851 /** @return question_attempt_step */ 1852 #[\ReturnTypeWillChange] 1853 public function offsetGet($i) { 1854 return $this->qa->get_step($i); 1855 } 1856 public function offsetSet($offset, $value): void { 1857 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.'); 1858 } 1859 public function offsetUnset($offset): void { 1860 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.'); 1861 } 1862 } 1863 1864 1865 /** 1866 * A variant of {@link question_attempt_step_iterator} that iterates through the 1867 * steps in reverse order. 1868 * 1869 * @copyright 2009 The Open University 1870 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1871 */ 1872 class question_attempt_reverse_step_iterator extends question_attempt_step_iterator { 1873 public function next(): void { 1874 --$this->i; 1875 } 1876 1877 public function rewind(): void { 1878 $this->i = $this->qa->get_num_steps() - 1; 1879 } 1880 } 1881 1882 /** 1883 * A variant of {@link question_attempt_step_iterator} that iterates through the 1884 * steps with submitted tries. 1885 * 1886 * @copyright 2014 The Open University 1887 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1888 */ 1889 class question_attempt_steps_with_submitted_response_iterator extends question_attempt_step_iterator implements Countable { 1890 1891 /** @var question_attempt the question_attempt being iterated over. */ 1892 protected $qa; 1893 1894 /** @var integer records the current position in the iteration. */ 1895 protected $submittedresponseno; 1896 1897 /** 1898 * Index is the submitted response number and value is the step no. 1899 * 1900 * @var int[] 1901 */ 1902 protected $stepswithsubmittedresponses; 1903 1904 /** 1905 * Do not call this constructor directly. 1906 * Use {@link question_attempt::get_submission_step_iterator()}. 1907 * @param question_attempt $qa the attempt to iterate over. 1908 */ 1909 public function __construct(question_attempt $qa) { 1910 $this->qa = $qa; 1911 $this->find_steps_with_submitted_response(); 1912 $this->rewind(); 1913 } 1914 1915 /** 1916 * Find the step nos in which a student has submitted a response. Including any step with a response that is saved before 1917 * the question attempt finishes. 1918 * 1919 * Called from constructor, should not be called from elsewhere. 1920 * 1921 */ 1922 protected function find_steps_with_submitted_response() { 1923 $stepnos = array(); 1924 $lastsavedstep = null; 1925 foreach ($this->qa->get_step_iterator() as $stepno => $step) { 1926 if ($this->qa->get_behaviour()->step_has_a_submitted_response($step)) { 1927 $stepnos[] = $stepno; 1928 $lastsavedstep = null; 1929 } else { 1930 $qtdata = $step->get_qt_data(); 1931 if (count($qtdata)) { 1932 $lastsavedstep = $stepno; 1933 } 1934 } 1935 } 1936 1937 if (!is_null($lastsavedstep)) { 1938 $stepnos[] = $lastsavedstep; 1939 } 1940 if (empty($stepnos)) { 1941 $this->stepswithsubmittedresponses = array(); 1942 } else { 1943 // Re-index array so index starts with 1. 1944 $this->stepswithsubmittedresponses = array_combine(range(1, count($stepnos)), $stepnos); 1945 } 1946 } 1947 1948 /** @return question_attempt_step */ 1949 #[\ReturnTypeWillChange] 1950 public function current() { 1951 return $this->offsetGet($this->submittedresponseno); 1952 } 1953 /** @return int */ 1954 #[\ReturnTypeWillChange] 1955 public function key() { 1956 return $this->submittedresponseno; 1957 } 1958 public function next(): void { 1959 ++$this->submittedresponseno; 1960 } 1961 public function rewind(): void { 1962 $this->submittedresponseno = 1; 1963 } 1964 /** @return bool */ 1965 public function valid(): bool { 1966 return $this->submittedresponseno >= 1 && $this->submittedresponseno <= count($this->stepswithsubmittedresponses); 1967 } 1968 1969 /** 1970 * @param int $submittedresponseno 1971 * @return bool 1972 */ 1973 public function offsetExists($submittedresponseno): bool { 1974 return $submittedresponseno >= 1; 1975 } 1976 1977 /** 1978 * @param int $submittedresponseno 1979 * @return question_attempt_step 1980 */ 1981 #[\ReturnTypeWillChange] 1982 public function offsetGet($submittedresponseno) { 1983 if ($submittedresponseno > count($this->stepswithsubmittedresponses)) { 1984 return null; 1985 } else { 1986 return $this->qa->get_step($this->step_no_for_try($submittedresponseno)); 1987 } 1988 } 1989 1990 /** 1991 * @return int the count of steps with tries. 1992 */ 1993 public function count(): int { 1994 return count($this->stepswithsubmittedresponses); 1995 } 1996 1997 /** 1998 * @param int $submittedresponseno 1999 * @throws coding_exception 2000 * @return int|null the step number or null if there is no such submitted response. 2001 */ 2002 public function step_no_for_try($submittedresponseno) { 2003 if (isset($this->stepswithsubmittedresponses[$submittedresponseno])) { 2004 return $this->stepswithsubmittedresponses[$submittedresponseno]; 2005 } else if ($submittedresponseno > count($this->stepswithsubmittedresponses)) { 2006 return null; 2007 } else { 2008 throw new coding_exception('Try number not found. It should be 1 or more.'); 2009 } 2010 } 2011 2012 public function offsetSet($offset, $value): void { 2013 throw new coding_exception('You are only allowed read-only access to question_attempt::states '. 2014 'through a question_attempt_step_iterator. Cannot set.'); 2015 } 2016 public function offsetUnset($offset): void { 2017 throw new coding_exception('You are only allowed read-only access to question_attempt::states '. 2018 'through a question_attempt_step_iterator. Cannot unset.'); 2019 } 2020 2021 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body