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