Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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  }