Search moodle.org's
Developer Documentation

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
  • Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 37 and 311] [Versions 38 and 311] [Versions 39 and 311]

       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 usage class, and a few related classes.
      19   *
      20   * @package    moodlecore
      21   * @subpackage questionengine
      22   * @copyright  2009 The Open University
      23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
      24   */
      25  
      26  
      27  defined('MOODLE_INTERNAL') || die();
      28  
      29  
      30  /**
      31   * This class keeps track of a group of questions that are being attempted,
      32   * and which state, and so on, each one is currently in.
      33   *
      34   * A quiz attempt or a lesson attempt could use an instance of this class to
      35   * keep track of all the questions in the attempt and process student submissions.
      36   * It is basically a collection of {@question_attempt} objects.
      37   *
      38   * The questions being attempted as part of this usage are identified by an integer
      39   * that is passed into many of the methods as $slot. ($question->id is not
      40   * used so that the same question can be used more than once in an attempt.)
      41   *
      42   * Normally, calling code should be able to do everything it needs to be calling
      43   * methods of this class. You should not normally need to get individual
      44   * {@question_attempt} objects and play around with their inner workind, in code
      45   * that it outside the quetsion engine.
      46   *
      47   * Instances of this class correspond to rows in the question_usages table.
      48   *
      49   * @copyright  2009 The Open University
      50   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
      51   */
      52  class question_usage_by_activity {
      53      /**
      54       * @var integer|string the id for this usage. If this usage was loaded from
      55       * the database, then this is the database id. Otherwise a unique random
      56       * string is used.
      57       */
      58      protected $id = null;
      59  
      60      /**
      61       * @var string name of an archetypal behaviour, that should be used
      62       * by questions in this usage if possible.
      63       */
      64      protected $preferredbehaviour = null;
      65  
      66      /** @var context the context this usage belongs to. */
      67      protected $context;
      68  
      69      /** @var string plugin name of the plugin this usage belongs to. */
      70      protected $owningcomponent;
      71  
      72      /** @var question_attempt[] {@link question_attempt}s that make up this usage. */
      73      protected $questionattempts = array();
      74  
      75      /** @var question_usage_observer that tracks changes to this usage. */
      76      protected $observer;
      77  
      78      /**
      79       * Create a new instance. Normally, calling code should use
      80       * {@link question_engine::make_questions_usage_by_activity()} or
      81       * {@link question_engine::load_questions_usage_by_activity()} rather than
      82       * calling this constructor directly.
      83       *
      84       * @param string $component the plugin creating this attempt. For example mod_quiz.
      85       * @param object $context the context this usage belongs to.
      86       */
      87      public function __construct($component, $context) {
      88          $this->owningcomponent = $component;
      89          $this->context = $context;
      90          $this->observer = new question_usage_null_observer();
      91      }
      92  
      93      /**
      94       * @param string $behaviour the name of an archetypal behaviour, that should
      95       * be used by questions in this usage if possible.
      96       */
      97      public function set_preferred_behaviour($behaviour) {
      98          $this->preferredbehaviour = $behaviour;
      99          $this->observer->notify_modified();
     100      }
     101  
     102      /** @return string the name of the preferred behaviour. */
     103      public function get_preferred_behaviour() {
     104          return $this->preferredbehaviour;
     105      }
     106  
     107      /** @return context the context this usage belongs to. */
     108      public function get_owning_context() {
     109          return $this->context;
     110      }
     111  
     112      /** @return string the name of the plugin that owns this attempt. */
     113      public function get_owning_component() {
     114          return $this->owningcomponent;
     115      }
     116  
     117      /** @return int|string If this usage came from the database, then the id
     118       * from the question_usages table is returned. Otherwise a random string is
     119       * returned. */
     120      public function get_id() {
     121          if (is_null($this->id)) {
     122              $this->id = random_string(10);
     123          }
     124          return $this->id;
     125      }
     126  
     127      /**
     128       * For internal use only. Used by {@link question_engine_data_mapper} to set
     129       * the id when a usage is saved to the database.
     130       * @param int $id the newly determined id for this usage.
     131       */
     132      public function set_id_from_database($id) {
     133          $this->id = $id;
     134          foreach ($this->questionattempts as $qa) {
     135              $qa->set_usage_id($id);
     136          }
     137      }
     138  
     139      /** @return question_usage_observer that is tracking changes made to this usage. */
     140      public function get_observer() {
     141          return $this->observer;
     142      }
     143  
     144      /**
     145       * You should almost certainly not call this method from your code. It is for
     146       * internal use only.
     147       * @param question_usage_observer that should be used to tracking changes made to this usage.
     148       */
     149      public function set_observer($observer) {
     150          $this->observer = $observer;
     151          foreach ($this->questionattempts as $qa) {
     152              $qa->set_observer($observer);
     153          }
     154      }
     155  
     156      /**
     157       * Add another question to this usage.
     158       *
     159       * The added question is not started until you call {@link start_question()}
     160       * on it.
     161       *
     162       * @param question_definition $question the question to add.
     163       * @param number $maxmark the maximum this question will be marked out of in
     164       *      this attempt (optional). If not given, $question->defaultmark is used.
     165       * @return int the number used to identify this question within this usage.
     166       */
     167      public function add_question(question_definition $question, $maxmark = null) {
     168          $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark);
     169          $qa->set_slot($this->next_slot_number());
     170          $this->questionattempts[$this->next_slot_number()] = $qa;
     171          $this->observer->notify_attempt_added($qa);
     172          return $qa->get_slot();
     173      }
     174  
     175      /**
     176       * Add another question to this usage, in the place of an existing slot.
     177       * The question_attempt that was in that slot is moved to the end at a new
     178       * slot number, which is returned.
     179       *
     180       * The added question is not started until you call {@link start_question()}
     181       * on it.
     182       *
     183       * @param int $slot the slot-number of the question to replace.
     184       * @param question_definition $question the question to add.
     185       * @param number $maxmark the maximum this question will be marked out of in
     186       *      this attempt (optional). If not given, the max mark from the $qa we
     187       *      are replacing is used.
     188       * @return int the new slot number of the question that was displaced.
     189       */
     190      public function add_question_in_place_of_other($slot, question_definition $question, $maxmark = null) {
     191          $newslot = $this->next_slot_number();
     192  
     193          $oldqa = $this->get_question_attempt($slot);
     194          $oldqa->set_slot($newslot);
     195          $this->questionattempts[$newslot] = $oldqa;
     196  
     197          if ($maxmark === null) {
     198              $maxmark = $oldqa->get_max_mark();
     199          }
     200  
     201          $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark);
     202          $qa->set_slot($slot);
     203          $this->questionattempts[$slot] = $qa;
     204  
     205          $this->observer->notify_attempt_moved($oldqa, $slot);
     206          $this->observer->notify_attempt_added($qa);
     207  
     208          return $newslot;
     209      }
     210  
     211      /**
     212       * The slot number that will be allotted to the next question added.
     213       */
     214      public function next_slot_number() {
     215          return count($this->questionattempts) + 1;
     216      }
     217  
     218      /**
     219       * Get the question_definition for a question in this attempt.
     220       * @param int $slot the number used to identify this question within this usage.
     221       * @param bool $requirequestioninitialised set this to false if you don't need
     222       *      the behaviour initialised, which may improve performance.
     223       * @return question_definition the requested question object.
     224       */
     225      public function get_question($slot, $requirequestioninitialised = true) {
     226          return $this->get_question_attempt($slot)->get_question($requirequestioninitialised);
     227      }
     228  
     229      /** @return array all the identifying numbers of all the questions in this usage. */
     230      public function get_slots() {
     231          return array_keys($this->questionattempts);
     232      }
     233  
     234      /** @return int the identifying number of the first question that was added to this usage. */
     235      public function get_first_question_number() {
     236          reset($this->questionattempts);
     237          return key($this->questionattempts);
     238      }
     239  
     240      /** @return int the number of questions that are currently in this usage. */
     241      public function question_count() {
     242          return count($this->questionattempts);
     243      }
     244  
     245      /**
     246       * Note the part of the {@link question_usage_by_activity} comment that explains
     247       * that {@link question_attempt} objects should be considered part of the inner
     248       * workings of the question engine, and should not, if possible, be accessed directly.
     249       *
     250       * @return question_attempt_iterator for iterating over all the questions being
     251       * attempted. as part of this usage.
     252       */
     253      public function get_attempt_iterator() {
     254          return new question_attempt_iterator($this);
     255      }
     256  
     257      /**
     258       * Check whether $number actually corresponds to a question attempt that is
     259       * part of this usage. Throws an exception if not.
     260       *
     261       * @param int $slot a number allegedly identifying a question within this usage.
     262       */
     263      protected function check_slot($slot) {
     264          if (!array_key_exists($slot, $this->questionattempts)) {
     265              throw new coding_exception('There is no question_attempt number ' . $slot .
     266                      ' in this attempt.');
     267          }
     268      }
     269  
     270      /**
     271       * Note the part of the {@link question_usage_by_activity} comment that explains
     272       * that {@link question_attempt} objects should be considered part of the inner
     273       * workings of the question engine, and should not, if possible, be accessed directly.
     274       *
     275       * @param int $slot the number used to identify this question within this usage.
     276       * @return question_attempt the corresponding {@link question_attempt} object.
     277       */
     278      public function get_question_attempt($slot) {
     279          $this->check_slot($slot);
     280          return $this->questionattempts[$slot];
     281      }
     282  
     283      /**
     284       * Get the current state of the attempt at a question.
     285       * @param int $slot the number used to identify this question within this usage.
     286       * @return question_state.
     287       */
     288      public function get_question_state($slot) {
     289          return $this->get_question_attempt($slot)->get_state();
     290      }
     291  
     292      /**
     293       * @param int $slot the number used to identify this question within this usage.
     294       * @param bool $showcorrectness Whether right/partial/wrong states should
     295       * be distinguised.
     296       * @return string A brief textual description of the current state.
     297       */
     298      public function get_question_state_string($slot, $showcorrectness) {
     299          return $this->get_question_attempt($slot)->get_state_string($showcorrectness);
     300      }
     301  
     302      /**
     303       * @param int $slot the number used to identify this question within this usage.
     304       * @param bool $showcorrectness Whether right/partial/wrong states should
     305       * be distinguised.
     306       * @return string a CSS class name for the current state.
     307       */
     308      public function get_question_state_class($slot, $showcorrectness) {
     309          return $this->get_question_attempt($slot)->get_state_class($showcorrectness);
     310      }
     311  
     312      /**
     313       * Whether this attempt at a given question could be completed just by the
     314       * student interacting with the question, before {@link finish_question()} is called.
     315       *
     316       * @param int $slot the number used to identify this question within this usage.
     317       * @return boolean whether the attempt at the given question can finish naturally.
     318       */
     319      public function can_question_finish_during_attempt($slot) {
     320          return $this->get_question_attempt($slot)->can_finish_during_attempt();
     321      }
     322  
     323      /**
     324       * Get the time of the most recent action performed on a question.
     325       * @param int $slot the number used to identify this question within this usage.
     326       * @return int timestamp.
     327       */
     328      public function get_question_action_time($slot) {
     329          return $this->get_question_attempt($slot)->get_last_action_time();
     330      }
     331  
     332      /**
     333       * Get the current fraction awarded for the attempt at a question.
     334       * @param int $slot the number used to identify this question within this usage.
     335       * @return number|null The current fraction for this question, or null if one has
     336       * not been assigned yet.
     337       */
     338      public function get_question_fraction($slot) {
     339          return $this->get_question_attempt($slot)->get_fraction();
     340      }
     341  
     342      /**
     343       * Get the current mark awarded for the attempt at a question.
     344       * @param int $slot the number used to identify this question within this usage.
     345       * @return number|null The current mark for this question, or null if one has
     346       * not been assigned yet.
     347       */
     348      public function get_question_mark($slot) {
     349          return $this->get_question_attempt($slot)->get_mark();
     350      }
     351  
     352      /**
     353       * Get the maximum mark possible for the attempt at a question.
     354       * @param int $slot the number used to identify this question within this usage.
     355       * @return number the available marks for this question.
     356       */
     357      public function get_question_max_mark($slot) {
     358          return $this->get_question_attempt($slot)->get_max_mark();
     359      }
     360  
     361      /**
     362       * Get the total mark for all questions in this usage.
     363       * @return number The sum of marks of all the question_attempts in this usage.
     364       */
     365      public function get_total_mark() {
     366          $mark = 0;
     367          foreach ($this->questionattempts as $qa) {
     368              if ($qa->get_max_mark() > 0 && $qa->get_state() == question_state::$needsgrading) {
     369                  return null;
     370              }
     371              $mark += $qa->get_mark();
     372          }
     373          return $mark;
     374      }
     375  
     376      /**
     377       * Get summary information about this usage.
     378       *
     379       * Some behaviours may be able to provide interesting summary information
     380       * about the attempt as a whole, and this method provides access to that data.
     381       * To see how this works, try setting a quiz to one of the CBM behaviours,
     382       * and then look at the extra information displayed at the top of the quiz
     383       * review page once you have sumitted an attempt.
     384       *
     385       * In the return value, the array keys are identifiers of the form
     386       * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary.
     387       * The values are arrays with two items, title and content. Each of these
     388       * will be either a string, or a renderable.
     389       *
     390       * @param question_display_options $options display options to apply.
     391       * @return array as described above.
     392       */
     393      public function get_summary_information(question_display_options $options) {
     394          return question_engine::get_behaviour_type($this->preferredbehaviour)
     395                  ->summarise_usage($this, $options);
     396      }
     397  
     398      /**
     399       * Get a simple textual summary of the question that was asked.
     400       *
     401       * @param int $slot the slot number of the question to summarise.
     402       * @return string the question summary.
     403       */
     404      public function get_question_summary($slot) {
     405          return $this->get_question_attempt($slot)->get_question_summary();
     406      }
     407  
     408      /**
     409       * Get a simple textual summary of response given.
     410       *
     411       * @param int $slot the slot number of the question to get the response summary for.
     412       * @return string the response summary.
     413       */
     414      public function get_response_summary($slot) {
     415          return $this->get_question_attempt($slot)->get_response_summary();
     416      }
     417  
     418      /**
     419       * Get a simple textual summary of the correct response to a question.
     420       *
     421       * @param int $slot the slot number of the question to get the right answer summary for.
     422       * @return string the right answer summary.
     423       */
     424      public function get_right_answer_summary($slot) {
     425          return $this->get_question_attempt($slot)->get_right_answer_summary();
     426      }
     427  
     428      /**
     429       * Return one of the bits of metadata for a particular question attempt in
     430       * this usage.
     431       * @param int $slot the slot number of the question of inereest.
     432       * @param string $name the name of the metadata variable to return.
     433       * @return string the value of that metadata variable.
     434       */
     435      public function get_question_attempt_metadata($slot, $name) {
     436          return $this->get_question_attempt($slot)->get_metadata($name);
     437      }
     438  
     439      /**
     440       * Set some metadata for a particular question attempt in this usage.
     441       * @param int $slot the slot number of the question of inerest.
     442       * @param string $name the name of the metadata variable to return.
     443       * @param string $value the value to set that metadata variable to.
     444       */
     445      public function set_question_attempt_metadata($slot, $name, $value) {
     446          $this->get_question_attempt($slot)->set_metadata($name, $value);
     447      }
     448  
     449      /**
     450       * Get the {@link core_question_renderer}, in collaboration with appropriate
     451       * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the
     452       * HTML to display this question.
     453       * @param int $slot the number used to identify this question within this usage.
     454       * @param question_display_options $options controls how the question is rendered.
     455       * @param string|null $number The question number to display. 'i' is a special
     456       *      value that gets displayed as Information. Null means no number is displayed.
     457       * @return string HTML fragment representing the question.
     458       */
     459      public function render_question($slot, $options, $number = null) {
     460          $options->context = $this->context;
     461          return $this->get_question_attempt($slot)->render($options, $number);
     462      }
     463  
     464      /**
     465       * Generate any bits of HTML that needs to go in the <head> tag when this question
     466       * is displayed in the body.
     467       * @param int $slot the number used to identify this question within this usage.
     468       * @return string HTML fragment.
     469       */
     470      public function render_question_head_html($slot) {
     471          //$options->context = $this->context;
     472          return $this->get_question_attempt($slot)->render_head_html();
     473      }
     474  
     475      /**
     476       * Like {@link render_question()} but displays the question at the past step
     477       * indicated by $seq, rather than showing the latest step.
     478       *
     479       * @param int $slot the number used to identify this question within this usage.
     480       * @param int $seq the seq number of the past state to display.
     481       * @param question_display_options $options controls how the question is rendered.
     482       * @param string|null $number The question number to display. 'i' is a special
     483       *      value that gets displayed as Information. Null means no number is displayed.
     484       * @return string HTML fragment representing the question.
     485       */
     486      public function render_question_at_step($slot, $seq, $options, $number = null) {
     487          $options->context = $this->context;
     488          return $this->get_question_attempt($slot)->render_at_step(
     489                  $seq, $options, $number, $this->preferredbehaviour);
     490      }
     491  
     492      /**
     493       * Checks whether the users is allow to be served a particular file.
     494       * @param int $slot the number used to identify this question within this usage.
     495       * @param question_display_options $options the options that control display of the question.
     496       * @param string $component the name of the component we are serving files for.
     497       * @param string $filearea the name of the file area.
     498       * @param array $args the remaining bits of the file path.
     499       * @param bool $forcedownload whether the user must be forced to download the file.
     500       * @return bool true if the user can access this file.
     501       */
     502      public function check_file_access($slot, $options, $component, $filearea,
     503              $args, $forcedownload) {
     504          return $this->get_question_attempt($slot)->check_file_access(
     505                  $options, $component, $filearea, $args, $forcedownload);
     506      }
     507  
     508      /**
     509       * Replace a particular question_attempt with a different one.
     510       *
     511       * For internal use only. Used when reloading the state of a question from the
     512       * database.
     513       *
     514       * @param int $slot the slot number of the question to replace.
     515       * @param question_attempt $qa the question attempt to put in that place.
     516       */
     517      public function replace_loaded_question_attempt_info($slot, $qa) {
     518          $this->check_slot($slot);
     519          $this->questionattempts[$slot] = $qa;
     520      }
     521  
     522      /**
     523       * You should probably not use this method in code outside the question engine.
     524       * The main reason for exposing it was for the benefit of unit tests.
     525       * @param int $slot the number used to identify this question within this usage.
     526       * @return string return the prefix that is pre-pended to field names in the HTML
     527       * that is output.
     528       */
     529      public function get_field_prefix($slot) {
     530          return $this->get_question_attempt($slot)->get_field_prefix();
     531      }
     532  
     533      /**
     534       * Get the number of variants available for the question in this slot.
     535       * @param int $slot the number used to identify this question within this usage.
     536       * @return int the number of variants available.
     537       */
     538      public function get_num_variants($slot) {
     539          return $this->get_question_attempt($slot)->get_question()->get_num_variants();
     540      }
     541  
     542      /**
     543       * Get the variant of the question being used in a given slot.
     544       * @param int $slot the number used to identify this question within this usage.
     545       * @return int the variant of this question that is being used.
     546       */
     547      public function get_variant($slot) {
     548          return $this->get_question_attempt($slot)->get_variant();
     549      }
     550  
     551      /**
     552       * Start the attempt at a question that has been added to this usage.
     553       * @param int $slot the number used to identify this question within this usage.
     554       * @param int $variant which variant of the question to use. Must be between
     555       *      1 and ->get_num_variants($slot) inclusive. If not give, a variant is
     556       *      chosen at random.
     557       * @param int|null $timenow optional, the timstamp to record for this action. Defaults to now.
     558       */
     559      public function start_question($slot, $variant = null, $timenow = null) {
     560          if (is_null($variant)) {
     561              $variant = rand(1, $this->get_num_variants($slot));
     562          }
     563  
     564          $qa = $this->get_question_attempt($slot);
     565          $qa->start($this->preferredbehaviour, $variant, array(), $timenow);
     566          $this->observer->notify_attempt_modified($qa);
     567      }
     568  
     569      /**
     570       * Start the attempt at all questions that has been added to this usage.
     571       * @param question_variant_selection_strategy how to pick which variant of each question to use.
     572       * @param int $timestamp optional, the timstamp to record for this action. Defaults to now.
     573       * @param int $userid optional, the user to attribute this action to. Defaults to the current user.
     574       */
     575      public function start_all_questions(question_variant_selection_strategy $variantstrategy = null,
     576              $timestamp = null, $userid = null) {
     577          if (is_null($variantstrategy)) {
     578              $variantstrategy = new question_variant_random_strategy();
     579          }
     580  
     581          foreach ($this->questionattempts as $qa) {
     582              $qa->start($this->preferredbehaviour, $qa->select_variant($variantstrategy), array(),
     583                      $timestamp, $userid);
     584              $this->observer->notify_attempt_modified($qa);
     585          }
     586      }
     587  
     588      /**
     589       * Start the attempt at a question, starting from the point where the previous
     590       * question_attempt $oldqa had reached. This is used by the quiz 'Each attempt
     591       * builds on last' mode.
     592       * @param int $slot the number used to identify this question within this usage.
     593       * @param question_attempt $oldqa a previous attempt at this quetsion that
     594       *      defines the starting point.
     595       */
     596      public function start_question_based_on($slot, question_attempt $oldqa) {
     597          $qa = $this->get_question_attempt($slot);
     598          $qa->start_based_on($oldqa);
     599          $this->observer->notify_attempt_modified($qa);
     600      }
     601  
     602      /**
     603       * Process all the question actions in the current request.
     604       *
     605       * If there is a parameter slots included in the post data, then only
     606       * those question numbers will be processed, otherwise all questions in this
     607       * useage will be.
     608       *
     609       * This function also does {@link update_question_flags()}.
     610       *
     611       * @param int $timestamp optional, use this timestamp as 'now'.
     612       * @param array $postdata optional, only intended for testing. Use this data
     613       * instead of the data from $_POST.
     614       */
     615      public function process_all_actions($timestamp = null, $postdata = null) {
     616          foreach ($this->get_slots_in_request($postdata) as $slot) {
     617              if (!$this->validate_sequence_number($slot, $postdata)) {
     618                  continue;
     619              }
     620              $submitteddata = $this->extract_responses($slot, $postdata);
     621              $this->process_action($slot, $submitteddata, $timestamp);
     622          }
     623          $this->update_question_flags($postdata);
     624      }
     625  
     626      /**
     627       * Process all the question autosave data in the current request.
     628       *
     629       * If there is a parameter slots included in the post data, then only
     630       * those question numbers will be processed, otherwise all questions in this
     631       * useage will be.
     632       *
     633       * This function also does {@link update_question_flags()}.
     634       *
     635       * @param int $timestamp optional, use this timestamp as 'now'.
     636       * @param array $postdata optional, only intended for testing. Use this data
     637       * instead of the data from $_POST.
     638       */
     639      public function process_all_autosaves($timestamp = null, $postdata = null) {
     640          foreach ($this->get_slots_in_request($postdata) as $slot) {
     641              if (!$this->is_autosave_required($slot, $postdata)) {
     642                  continue;
     643              }
     644              $submitteddata = $this->extract_responses($slot, $postdata);
     645              $this->process_autosave($slot, $submitteddata, $timestamp);
     646          }
     647          $this->update_question_flags($postdata);
     648      }
     649  
     650      /**
     651       * Get the list of slot numbers that should be processed as part of processing
     652       * the current request.
     653       * @param array $postdata optional, only intended for testing. Use this data
     654       * instead of the data from $_POST.
     655       * @return array of slot numbers.
     656       */
     657      protected function get_slots_in_request($postdata = null) {
     658          // Note: we must not use "question_attempt::get_submitted_var()" because there is no attempt instance!!!
     659          if (is_null($postdata)) {
     660              $slots = optional_param('slots', null, PARAM_SEQUENCE);
     661          } else if (array_key_exists('slots', $postdata)) {
     662              $slots = clean_param($postdata['slots'], PARAM_SEQUENCE);
     663          } else {
     664              $slots = null;
     665          }
     666          if (is_null($slots)) {
     667              $slots = $this->get_slots();
     668          } else if (!$slots) {
     669              $slots = array();
     670          } else {
     671              $slots = explode(',', $slots);
     672          }
     673          return $slots;
     674      }
     675  
     676      /**
     677       * Get the submitted data from the current request that belongs to this
     678       * particular question.
     679       *
     680       * @param int $slot the number used to identify this question within this usage.
     681       * @param array|null $postdata optional, only intended for testing. Use this data
     682       * instead of the data from $_POST.
     683       * @return array submitted data specific to this question.
     684       */
     685      public function extract_responses($slot, $postdata = null) {
     686          return $this->get_question_attempt($slot)->get_submitted_data($postdata);
     687      }
     688  
     689      /**
     690       * Transform an array of response data for slots to an array of post data as you would get from quiz attempt form.
     691       *
     692       * @param $simulatedresponses array keys are slot nos => contains arrays representing student
     693       *                                   responses which will be passed to question_definition::prepare_simulated_post_data method
     694       *                                   and then have the appropriate prefix added.
     695       * @return array simulated post data
     696       */
     697      public function prepare_simulated_post_data($simulatedresponses) {
     698          $simulatedpostdata = array();
     699          $simulatedpostdata['slots'] = implode(',', array_keys($simulatedresponses));
     700          foreach ($simulatedresponses as $slot => $responsedata) {
     701              $slotresponse = array();
     702  
     703              // Behaviour vars should not be processed by question type, just add prefix.
     704              $behaviourvars = $this->get_question_attempt($slot)->get_behaviour()->get_expected_data();
     705              foreach (array_keys($responsedata) as $responsedatakey) {
     706                  if (is_string($responsedatakey) && $responsedatakey[0] === '-') {
     707                      $behaviourvarname = substr($responsedatakey, 1);
     708                      if (isset($behaviourvars[$behaviourvarname])) {
     709                          // Expected behaviour var found.
     710                          if ($responsedata[$responsedatakey]) {
     711                              // Only set the behaviour var if the column value from the cvs file is non zero.
     712                              // The behaviours only look at whether the var is set or not they don't look at the value.
     713                              $slotresponse[$responsedatakey] = $responsedata[$responsedatakey];
     714                          }
     715                      }
     716                      // Remove both expected and unexpected vars from data passed to question type.
     717                      unset($responsedata[$responsedatakey]);
     718                  }
     719              }
     720  
     721              $slotresponse += $this->get_question($slot)->prepare_simulated_post_data($responsedata);
     722              $slotresponse[':sequencecheck'] = $this->get_question_attempt($slot)->get_sequence_check_count();
     723  
     724              // Add this slot's prefix to slot data.
     725              $prefix = $this->get_field_prefix($slot);
     726              foreach ($slotresponse as $key => $value) {
     727                  $simulatedpostdata[$prefix.$key] = $value;
     728              }
     729          }
     730          return $simulatedpostdata;
     731      }
     732  
     733      /**
     734       * Process a specific action on a specific question.
     735       * @param int $slot the number used to identify this question within this usage.
     736       * @param array $submitteddata the submitted data that constitutes the action.
     737       * @param int|null $timestamp (optional) the timestamp to consider 'now'.
     738       */
     739      public function process_action($slot, $submitteddata, $timestamp = null) {
     740          $qa = $this->get_question_attempt($slot);
     741          $qa->process_action($submitteddata, $timestamp);
     742          $this->observer->notify_attempt_modified($qa);
     743      }
     744  
     745      /**
     746       * Process an autosave action on a specific question.
     747       * @param int $slot the number used to identify this question within this usage.
     748       * @param array $submitteddata the submitted data that constitutes the action.
     749       * @param int|null $timestamp (optional) the timestamp to consider 'now'.
     750       */
     751      public function process_autosave($slot, $submitteddata, $timestamp = null) {
     752          $qa = $this->get_question_attempt($slot);
     753          if ($qa->process_autosave($submitteddata, $timestamp)) {
     754              $this->observer->notify_attempt_modified($qa);
     755          }
     756      }
     757  
     758      /**
     759       * Check that the sequence number, that detects weird things like the student clicking back, is OK.
     760       *
     761       * If the sequence check variable is not present, returns
     762       * false. If the check variable is present and correct, returns true. If the
     763       * variable is present and wrong, throws an exception.
     764       *
     765       * @param int $slot the number used to identify this question within this usage.
     766       * @param array|null $postdata (optional) data to use in place of $_POST.
     767       * @return bool true if the check variable is present and correct. False if it
     768       * is missing. (Throws an exception if the check fails.)
     769       */
     770      public function validate_sequence_number($slot, $postdata = null) {
     771          $qa = $this->get_question_attempt($slot);
     772          $sequencecheck = $qa->get_submitted_var(
     773                  $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata);
     774          if (is_null($sequencecheck)) {
     775              return false;
     776          } else if ($sequencecheck != $qa->get_sequence_check_count()) {
     777              throw new question_out_of_sequence_exception($this->id, $slot, $postdata);
     778          } else {
     779              return true;
     780          }
     781      }
     782  
     783      /**
     784       * Check, based on the sequence number, whether this auto-save is still required.
     785       *
     786       * @param int $slot the number used to identify this question within this usage.
     787       * @param array|null $postdata the submitted data that constitutes the action.
     788       * @return bool true if the check variable is present and correct, otherwise false.
     789       */
     790      public function is_autosave_required($slot, $postdata = null) {
     791          $qa = $this->get_question_attempt($slot);
     792          $sequencecheck = $qa->get_submitted_var(
     793                  $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata);
     794          if (is_null($sequencecheck)) {
     795              return false;
     796          } else if ($sequencecheck != $qa->get_sequence_check_count()) {
     797              return false;
     798          } else {
     799              return true;
     800          }
     801      }
     802  
     803      /**
     804       * Update the flagged state for all question_attempts in this usage, if their
     805       * flagged state was changed in the request.
     806       *
     807       * @param array|null $postdata optional, only intended for testing. Use this data
     808       * instead of the data from $_POST.
     809       */
     810      public function update_question_flags($postdata = null) {
     811          foreach ($this->questionattempts as $qa) {
     812              $flagged = $qa->get_submitted_var(
     813                      $qa->get_flag_field_name(), PARAM_BOOL, $postdata);
     814              if (!is_null($flagged) && $flagged != $qa->is_flagged()) {
     815                  $qa->set_flagged($flagged);
     816              }
     817          }
     818      }
     819  
     820      /**
     821       * Get the correct response to a particular question. Passing the results of
     822       * this method to {@link process_action()} will probably result in full marks.
     823       * If it is not possible to compute a correct response, this method should return null.
     824       * @param int $slot the number used to identify this question within this usage.
     825       * @return array that constitutes a correct response to this question.
     826       */
     827      public function get_correct_response($slot) {
     828          return $this->get_question_attempt($slot)->get_correct_response();
     829      }
     830  
     831      /**
     832       * Finish the active phase of an attempt at a question.
     833       *
     834       * This is an external act of finishing the attempt. Think, for example, of
     835       * the 'Submit all and finish' button in the quiz. Some behaviours,
     836       * (for example, immediatefeedback) give a way of finishing the active phase
     837       * of a question attempt as part of a {@link process_action()} call.
     838       *
     839       * After the active phase is over, the only changes possible are things like
     840       * manual grading, or changing the flag state.
     841       *
     842       * @param int $slot the number used to identify this question within this usage.
     843       * @param int|null $timestamp (optional) the timestamp to consider 'now'.
     844       */
     845      public function finish_question($slot, $timestamp = null) {
     846          $qa = $this->get_question_attempt($slot);
     847          $qa->finish($timestamp);
     848          $this->observer->notify_attempt_modified($qa);
     849      }
     850  
     851      /**
     852       * Finish the active phase of an attempt at a question. See {@link finish_question()}
     853       * for a fuller description of what 'finish' means.
     854       *
     855       * @param int|null $timestamp (optional) the timestamp to consider 'now'.
     856       */
     857      public function finish_all_questions($timestamp = null) {
     858          foreach ($this->questionattempts as $qa) {
     859              $qa->finish($timestamp);
     860              $this->observer->notify_attempt_modified($qa);
     861          }
     862      }
     863  
     864      /**
     865       * Perform a manual grading action on a question attempt.
     866       * @param int $slot the number used to identify this question within this usage.
     867       * @param string $comment the comment being added to the question attempt.
     868       * @param number $mark the mark that is being assigned. Can be null to just
     869       * add a comment.
     870       * @param int $commentformat one of the FORMAT_... constants. The format of $comment.
     871       */
     872      public function manual_grade($slot, $comment, $mark, $commentformat = null) {
     873          $qa = $this->get_question_attempt($slot);
     874          $qa->manual_grade($comment, $mark, $commentformat);
     875          $this->observer->notify_attempt_modified($qa);
     876      }
     877  
     878      /**
     879       * Regrade a question in this usage. This replays the sequence of submitted
     880       * actions to recompute the outcomes.
     881       * @param int $slot the number used to identify this question within this usage.
     882       * @param bool $finished whether the question attempt should be forced to be finished
     883       *      after the regrade, or whether it may still be in progress (default false).
     884       * @param number $newmaxmark (optional) if given, will change the max mark while regrading.
     885       */
     886      public function regrade_question($slot, $finished = false, $newmaxmark = null) {
     887          $oldqa = $this->get_question_attempt($slot);
     888          if (is_null($newmaxmark)) {
     889              $newmaxmark = $oldqa->get_max_mark();
     890          }
     891  
     892          $newqa = new question_attempt($oldqa->get_question(false), $oldqa->get_usage_id(),
     893                  $this->observer, $newmaxmark);
     894          $newqa->set_database_id($oldqa->get_database_id());
     895          $newqa->set_slot($oldqa->get_slot());
     896          $newqa->regrade($oldqa, $finished);
     897  
     898          $this->questionattempts[$slot] = $newqa;
     899          $this->observer->notify_attempt_modified($newqa);
     900      }
     901  
     902      /**
     903       * Regrade all the questions in this usage (without changing their max mark).
     904       * @param bool $finished whether each question should be forced to be finished
     905       *      after the regrade, or whether it may still be in progress (default false).
     906       */
     907      public function regrade_all_questions($finished = false) {
     908          foreach ($this->questionattempts as $slot => $notused) {
     909              $this->regrade_question($slot, $finished);
     910          }
     911      }
     912  
     913      /**
     914       * Change the max mark for this question_attempt.
     915       * @param int $slot the slot number of the question of inerest.
     916       * @param float $maxmark the new max mark.
     917       */
     918      public function set_max_mark($slot, $maxmark) {
     919          $this->get_question_attempt($slot)->set_max_mark($maxmark);
     920      }
     921  
     922      /**
     923       * Create a question_usage_by_activity from records loaded from the database.
     924       *
     925       * For internal use only.
     926       *
     927       * @param Iterator $records Raw records loaded from the database.
     928       * @param int $qubaid The id of the question usage we are loading.
     929       * @return question_usage_by_activity The newly constructed usage.
     930       */
     931      public static function load_from_records($records, $qubaid) {
     932          $record = $records->current();
     933          while ($record->qubaid != $qubaid) {
     934              $records->next();
     935              if (!$records->valid()) {
     936                  throw new coding_exception("Question usage {$qubaid} not found in the database.");
     937              }
     938              $record = $records->current();
     939          }
     940  
     941          $quba = new question_usage_by_activity($record->component,
     942              context::instance_by_id($record->contextid, IGNORE_MISSING));
     943          $quba->set_id_from_database($record->qubaid);
     944          $quba->set_preferred_behaviour($record->preferredbehaviour);
     945  
     946          $quba->observer = new question_engine_unit_of_work($quba);
     947  
     948          // If slot is null then the current pointer in $records will not be
     949          // advanced in the while loop below, and we get stuck in an infinite loop,
     950          // since this method is supposed to always consume at least one record.
     951          // Therefore, in this case, advance the record here.
     952          if (is_null($record->slot)) {
     953              $records->next();
     954          }
     955  
     956          while ($record && $record->qubaid == $qubaid && !is_null($record->slot)) {
     957              $quba->questionattempts[$record->slot] =
     958                      question_attempt::load_from_records($records,
     959                      $record->questionattemptid, $quba->observer,
     960                      $quba->get_preferred_behaviour());
     961              if ($records->valid()) {
     962                  $record = $records->current();
     963              } else {
     964                  $record = false;
     965              }
     966          }
     967  
     968          return $quba;
     969      }
     970  
     971      /**
     972       * Preload users of all question attempt steps.
     973       *
     974       * @throws dml_exception
     975       */
     976      public function preload_all_step_users(): void {
     977          global $DB;
     978  
     979          // Get all user ids.
     980          $userids = [];
     981          foreach ($this->questionattempts as $qa) {
     982              foreach ($qa->get_full_step_iterator() as $step) {
     983                  $userids[$step->get_user_id()] = 1;
     984              }
     985          }
     986  
     987          // Load user information.
     988          $users = $DB->get_records_list('user', 'id', array_keys($userids), '', '*');
     989          // Update user information for steps.
     990          foreach ($this->questionattempts as $qa) {
     991              foreach ($qa->get_full_step_iterator() as $step) {
     992                  $user = $users[$step->get_user_id()];
     993                  if (isset($user)) {
     994                      $step->add_full_user_object($user);
     995                  }
     996              }
     997          }
     998      }
     999  }
    1000  
    1001  
    1002  /**
    1003   * A class abstracting access to the {@link question_usage_by_activity::$questionattempts} array.
    1004   *
    1005   * This class snapshots the list of {@link question_attempts} to iterate over
    1006   * when it is created. If a question is added to the usage mid-iteration, it
    1007   * will now show up.
    1008   *
    1009   * To create an instance of this class, use
    1010   * {@link question_usage_by_activity::get_attempt_iterator()}
    1011   *
    1012   * @copyright  2009 The Open University
    1013   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
    1014   */
    1015  class question_attempt_iterator implements Iterator, ArrayAccess {
    1016  
    1017      /** @var question_usage_by_activity that we are iterating over. */
    1018      protected $quba;
    1019  
    1020      /** @var array of slot numbers. */
    1021      protected $slots;
    1022  
    1023      /**
    1024       * To create an instance of this class, use
    1025       * {@link question_usage_by_activity::get_attempt_iterator()}.
    1026       *
    1027       * @param question_usage_by_activity $quba the usage to iterate over.
    1028       */
    1029      public function __construct(question_usage_by_activity $quba) {
    1030          $this->quba = $quba;
    1031          $this->slots = $quba->get_slots();
    1032          $this->rewind();
    1033      }
    1034  
    1035      /**
    1036       * Standard part of the Iterator interface.
    1037       *
    1038       * @return question_attempt
    1039       */
    1040      public function current() {
    1041          return $this->offsetGet(current($this->slots));
    1042      }
    1043  
    1044      /**
    1045       * Standard part of the Iterator interface.
    1046       *
    1047       * @return int
    1048       */
    1049      public function key() {
    1050          return current($this->slots);
    1051      }
    1052  
    1053      /**
    1054       * Standard part of the Iterator interface.
    1055       */
    1056      public function next() {
    1057          next($this->slots);
    1058      }
    1059  
    1060      /**
    1061       * Standard part of the Iterator interface.
    1062       */
    1063      public function rewind() {
    1064          reset($this->slots);
    1065      }
    1066  
    1067      /**
    1068       * Standard part of the Iterator interface.
    1069       *
    1070       * @return bool
    1071       */
    1072      public function valid() {
    1073          return current($this->slots) !== false;
    1074      }
    1075  
    1076      /**
    1077       * Standard part of the ArrayAccess interface.
    1078       *
    1079       * @param int $slot
    1080       * @return bool
    1081       */
    1082      public function offsetExists($slot) {
    1083          return in_array($slot, $this->slots);
    1084      }
    1085  
    1086      /**
    1087       * Standard part of the ArrayAccess interface.
    1088       *
    1089       * @param int $slot
    1090       * @return question_attempt
    1091       */
    1092      public function offsetGet($slot) {
    1093          return $this->quba->get_question_attempt($slot);
    1094      }
    1095  
    1096      /**
    1097       * Standard part of the ArrayAccess interface.
    1098       *
    1099       * @param int $slot
    1100       * @param question_attempt $value
    1101       */
    1102      public function offsetSet($slot, $value) {
    1103          throw new coding_exception('You are only allowed read-only access to ' .
    1104                  'question_attempt::states through a question_attempt_step_iterator. Cannot set.');
    1105      }
    1106  
    1107      /**
    1108       * Standard part of the ArrayAccess interface.
    1109       *
    1110       * @param int $slot
    1111       */
    1112      public function offsetUnset($slot) {
    1113          throw new coding_exception('You are only allowed read-only access to ' .
    1114                  'question_attempt::states through a question_attempt_step_iterator. Cannot unset.');
    1115      }
    1116  }
    1117  
    1118  
    1119  /**
    1120   * Interface for things that want to be notified of signficant changes to a
    1121   * {@link question_usage_by_activity}.
    1122   *
    1123   * A question behaviour controls the flow of actions a student can
    1124   * take as they work through a question, and later, as a teacher manually grades it.
    1125   *
    1126   * @copyright  2009 The Open University
    1127   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
    1128   */
    1129  interface question_usage_observer {
    1130      /** Called when a field of the question_usage_by_activity is changed. */
    1131      public function notify_modified();
    1132  
    1133      /**
    1134       * Called when a new question attempt is added to this usage.
    1135       * @param question_attempt $qa the newly added question attempt.
    1136       */
    1137      public function notify_attempt_added(question_attempt $qa);
    1138  
    1139      /**
    1140       * Called when the fields of a question attempt in this usage are modified.
    1141       * @param question_attempt $qa the newly added question attempt.
    1142       */
    1143      public function notify_attempt_modified(question_attempt $qa);
    1144  
    1145      /**
    1146       * Called when a question_attempt has been moved to a new slot.
    1147       * @param question_attempt $qa The question attempt that was moved.
    1148       * @param int $oldslot The previous slot number of that attempt.
    1149       */
    1150      public function notify_attempt_moved(question_attempt $qa, $oldslot);
    1151  
    1152      /**
    1153       * Called when a new step is added to a question attempt in this usage.
    1154       * @param question_attempt_step $step the new step.
    1155       * @param question_attempt $qa the usage it is being added to.
    1156       * @param int $seq the sequence number of the new step.
    1157       */
    1158      public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq);
    1159  
    1160      /**
    1161       * Called when a new step is updated in a question attempt in this usage.
    1162       * @param question_attempt_step $step the step that was updated.
    1163       * @param question_attempt $qa the usage it is being added to.
    1164       * @param int $seq the sequence number of the new step.
    1165       */
    1166      public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq);
    1167  
    1168      /**
    1169       * Called when a new step is updated in a question attempt in this usage.
    1170       * @param question_attempt_step $step the step to delete.
    1171       * @param question_attempt $qa the usage it is being added to.
    1172       */
    1173      public function notify_step_deleted(question_attempt_step $step, question_attempt $qa);
    1174  
    1175      /**
    1176       * Called when a new metadata variable is set on a question attempt in this usage.
    1177       * @param question_attempt $qa the question attempt the metadata is being added to.
    1178       * @param int $name the name of the metadata variable added.
    1179       */
    1180      public function notify_metadata_added(question_attempt $qa, $name);
    1181  
    1182      /**
    1183       * Called when a metadata variable on a question attempt in this usage is updated.
    1184       * @param question_attempt $qa the question attempt where the metadata is being modified.
    1185       * @param int $name the name of the metadata variable modified.
    1186       */
    1187      public function notify_metadata_modified(question_attempt $qa, $name);
    1188  }
    1189  
    1190  
    1191  /**
    1192   * Null implmentation of the {@link question_usage_watcher} interface.
    1193   * Does nothing.
    1194   *
    1195   * @copyright  2009 The Open University
    1196   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
    1197   */
    1198  class question_usage_null_observer implements question_usage_observer {
    1199      public function notify_modified() {
    1200      }
    1201      public function notify_attempt_added(question_attempt $qa) {
    1202      }
    1203      public function notify_attempt_modified(question_attempt $qa) {
    1204      }
    1205      public function notify_attempt_moved(question_attempt $qa, $oldslot) {
    1206      }
    1207      public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
    1208      }
    1209      public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
    1210      }
    1211      public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
    1212      }
    1213      public function notify_metadata_added(question_attempt $qa, $name) {
    1214      }
    1215      public function notify_metadata_modified(question_attempt $qa, $name) {
    1216      }
    1217  }