Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401]

   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   * Definition class for embedded element in question text question.
  19   *
  20   * Used by gap-select, drag and drop and possibly others.
  21   *
  22   * @package    qtype_gapselect
  23   * @copyright  2011 The Open University
  24   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  25   */
  26  
  27  
  28  defined('MOODLE_INTERNAL') || die();
  29  
  30  require_once($CFG->dirroot . '/question/type/questionbase.php');
  31  
  32  /**
  33   * Represents embedded element in question text question.
  34   *
  35   * Parent of drag and drop and select from drop down list and others.
  36   *
  37   * @copyright  2011 The Open University
  38   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  abstract class qtype_gapselect_question_base extends question_graded_automatically_with_countback {
  41      /** @var boolean Whether the question stems should be shuffled. */
  42      public $shufflechoices;
  43  
  44      /** @var string Feedback for any correct response. */
  45      public $correctfeedback;
  46      /** @var int format of $correctfeedback. */
  47      public $correctfeedbackformat;
  48      /** @var string Feedback for any partially correct response. */
  49      public $partiallycorrectfeedback;
  50      /** @var int format of $partiallycorrectfeedback. */
  51      public $partiallycorrectfeedbackformat;
  52      /** @var string Feedback for any incorrect response. */
  53      public $incorrectfeedback;
  54      /** @var int format of $incorrectfeedback. */
  55      public $incorrectfeedbackformat;
  56  
  57      /**
  58       * @var array of arrays. The outer keys are the choice group numbers.
  59       * The inner keys for most question types number sequentialy from 1. However
  60       * for ddimageortext questions it is strange (and difficult to change now).
  61       * the first item in each group gets numbered 1, and the other items get numbered
  62       * $choice->no. Be careful!
  63       * The values are arrays of qtype_gapselect_choice objects (or a subclass).
  64       */
  65      public $choices;
  66  
  67      /**
  68       * @var array place number => group number of the places in the question
  69       * text where choices can be put. Places are numbered from 1.
  70       */
  71      public $places;
  72  
  73      /**
  74       * @var array of strings, one longer than $places, which is achieved by
  75       * indexing from 0. The bits of question text that go between the placeholders.
  76       */
  77      public $textfragments;
  78  
  79      /** @var array index of the right choice for each stem. */
  80      public $rightchoices;
  81  
  82      /** @var array shuffled choice indexes. */
  83      protected $choiceorder;
  84  
  85      public function start_attempt(question_attempt_step $step, $variant) {
  86          foreach ($this->choices as $group => $choices) {
  87              $choiceorder = array_keys($choices);
  88              if ($this->shufflechoices) {
  89                  shuffle($choiceorder);
  90              }
  91              $step->set_qt_var('_choiceorder' . $group, implode(',', $choiceorder));
  92              $this->set_choiceorder($group, $choiceorder);
  93          }
  94      }
  95  
  96      public function apply_attempt_state(question_attempt_step $step) {
  97          foreach ($this->choices as $group => $choices) {
  98              $this->set_choiceorder($group, explode(',',
  99                      $step->get_qt_var('_choiceorder' . $group)));
 100          }
 101      }
 102  
 103      /**
 104       * Helper method used by both {@link start_attempt()} and
 105       * {@link apply_attempt_state()}.
 106       * @param int $group the group number.
 107       * @param array $choiceorder the choices, in order.
 108       */
 109      protected function set_choiceorder($group, $choiceorder) {
 110          foreach ($choiceorder as $key => $value) {
 111              $this->choiceorder[$group][$key + 1] = $value;
 112          }
 113      }
 114  
 115      public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
 116          $basemessage = parent::validate_can_regrade_with_other_version($otherversion);
 117          if ($basemessage) {
 118              return $basemessage;
 119          }
 120  
 121          if (count($this->choices) != count($otherversion->choices)) {
 122              return get_string('regradeissuenumgroupsschanged', 'qtype_gapselect');
 123          }
 124  
 125          foreach ($this->choices as $group => $choices) {
 126              if (count($this->choices[$group]) != count($otherversion->choices[$group])) {
 127                  return get_string('regradeissuenumchoiceschanged', 'qtype_gapselect', $group);
 128              }
 129          }
 130  
 131          return null;
 132      }
 133  
 134      public function get_question_summary() {
 135          $question = $this->html_to_text($this->questiontext, $this->questiontextformat);
 136          $groups = array();
 137          foreach ($this->choices as $group => $choices) {
 138              $cs = array();
 139              foreach ($choices as $choice) {
 140                  $cs[] = html_to_text($choice->text, 0, false);
 141              }
 142              $groups[] = '[[' . $group . ']] -> {' . implode(' / ', $cs) . '}';
 143          }
 144          return $question . '; ' . implode('; ', $groups);
 145      }
 146  
 147      protected function get_selected_choice($group, $shuffledchoicenumber) {
 148          $choiceno = $this->choiceorder[$group][$shuffledchoicenumber];
 149          return isset($this->choices[$group][$choiceno]) ? $this->choices[$group][$choiceno] : null;
 150      }
 151  
 152      public function summarise_response(array $response) {
 153          $matches = array();
 154          $allblank = true;
 155          foreach ($this->places as $place => $group) {
 156              if (array_key_exists($this->field($place), $response) &&
 157                      $response[$this->field($place)]) {
 158                  $choices[] = '{' . $this->summarise_choice(
 159                          $this->get_selected_choice($group, $response[$this->field($place)])) . '}';
 160                  $allblank = false;
 161              } else {
 162                  $choices[] = '{}';
 163              }
 164          }
 165          if ($allblank) {
 166              return null;
 167          }
 168          return implode(' ', $choices);
 169      }
 170  
 171      /**
 172       * Convert a choice to plain text.
 173       * @param qtype_gapselect_choice $choice one of the choices for a place.
 174       * @return a plain text summary of the choice.
 175       */
 176      public function summarise_choice($choice) {
 177          return $this->html_to_text($choice->text, FORMAT_PLAIN);
 178      }
 179  
 180      public function get_random_guess_score() {
 181          $accum = 0;
 182  
 183          foreach ($this->places as $placegroup) {
 184              $accum += 1 / count($this->choices[$placegroup]);
 185          }
 186  
 187          return $accum / count($this->places);
 188      }
 189  
 190      public function clear_wrong_from_response(array $response) {
 191          foreach ($this->places as $place => $notused) {
 192              if (array_key_exists($this->field($place), $response) &&
 193                      $response[$this->field($place)] != $this->get_right_choice_for($place)) {
 194                  $response[$this->field($place)] = '0';
 195              }
 196          }
 197          return $response;
 198      }
 199  
 200      public function get_num_parts_right(array $response) {
 201          $numright = 0;
 202          foreach ($this->places as $place => $notused) {
 203              if (!array_key_exists($this->field($place), $response)) {
 204                  continue;
 205              }
 206              if ($response[$this->field($place)] == $this->get_right_choice_for($place)) {
 207                  $numright += 1;
 208              }
 209          }
 210          return array($numright, count($this->places));
 211      }
 212  
 213      /**
 214       * Get the field name corresponding to a given place.
 215       * @param int $place stem number
 216       * @return string the question-type variable name.
 217       */
 218      public function field($place) {
 219          return 'p' . $place;
 220      }
 221  
 222      public function get_expected_data() {
 223          $vars = array();
 224          foreach ($this->places as $place => $notused) {
 225              $vars[$this->field($place)] = PARAM_INTEGER;
 226          }
 227          return $vars;
 228      }
 229  
 230      public function get_correct_response() {
 231          $response = array();
 232          foreach ($this->places as $place => $notused) {
 233              $response[$this->field($place)] = $this->get_right_choice_for($place);
 234          }
 235          return $response;
 236      }
 237  
 238      public function get_right_choice_for($place) {
 239          $group = $this->places[$place];
 240          foreach ($this->choiceorder[$group] as $choicekey => $choiceid) {
 241              if ($this->rightchoices[$place] == $choiceid) {
 242                  return $choicekey;
 243              }
 244          }
 245      }
 246  
 247      public function get_ordered_choices($group) {
 248          $choices = array();
 249          foreach ($this->choiceorder[$group] as $choicekey => $choiceid) {
 250              $choices[$choicekey] = $this->choices[$group][$choiceid];
 251          }
 252          return $choices;
 253      }
 254  
 255      public function is_complete_response(array $response) {
 256          $complete = true;
 257          foreach ($this->places as $place => $notused) {
 258              $complete = $complete && !empty($response[$this->field($place)]);
 259          }
 260          return $complete;
 261      }
 262  
 263      public function is_gradable_response(array $response) {
 264          foreach ($this->places as $place => $notused) {
 265              if (!empty($response[$this->field($place)])) {
 266                  return true;
 267              }
 268          }
 269          return false;
 270      }
 271  
 272      public function is_same_response(array $prevresponse, array $newresponse) {
 273          foreach ($this->places as $place => $notused) {
 274              $fieldname = $this->field($place);
 275              if (!question_utils::arrays_same_at_key_integer(
 276                      $prevresponse, $newresponse, $fieldname)) {
 277                  return false;
 278              }
 279          }
 280          return true;
 281      }
 282  
 283      public function get_validation_error(array $response) {
 284          if ($this->is_complete_response($response)) {
 285              return '';
 286          }
 287          return get_string('pleaseputananswerineachbox', 'qtype_gapselect');
 288      }
 289  
 290      public function grade_response(array $response) {
 291          list($right, $total) = $this->get_num_parts_right($response);
 292          $fraction = $right / $total;
 293          return array($fraction, question_state::graded_state_for_fraction($fraction));
 294      }
 295  
 296      public function compute_final_grade($responses, $totaltries) {
 297          $totalscore = 0;
 298          foreach ($this->places as $place => $notused) {
 299              $fieldname = $this->field($place);
 300  
 301              $lastwrongindex = -1;
 302              $finallyright = false;
 303              foreach ($responses as $i => $response) {
 304                  if (!array_key_exists($fieldname, $response) ||
 305                          $response[$fieldname] != $this->get_right_choice_for($place)) {
 306                      $lastwrongindex = $i;
 307                      $finallyright = false;
 308                  } else {
 309                      $finallyright = true;
 310                  }
 311              }
 312  
 313              if ($finallyright) {
 314                  $totalscore += max(0, 1 - ($lastwrongindex + 1) * $this->penalty);
 315              }
 316          }
 317  
 318          return $totalscore / count($this->places);
 319      }
 320  
 321      public function classify_response(array $response) {
 322          $parts = array();
 323          foreach ($this->places as $place => $group) {
 324              if (!array_key_exists($this->field($place), $response) ||
 325                      !$response[$this->field($place)]) {
 326                  $parts[$place] = question_classified_response::no_response();
 327                  continue;
 328              }
 329  
 330              $fieldname = $this->field($place);
 331              $choiceno = $this->choiceorder[$group][$response[$fieldname]];
 332              $choice = $this->choices[$group][$choiceno];
 333              $parts[$place] = new question_classified_response(
 334                      $choiceno, html_to_text($choice->text, 0, false),
 335                      ($this->get_right_choice_for($place) == $response[$fieldname]) / count($this->places));
 336          }
 337          return $parts;
 338      }
 339  
 340      public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
 341          if ($component == 'question' && in_array($filearea,
 342                  array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) {
 343              return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
 344  
 345          } else if ($component == 'question' && $filearea == 'hint') {
 346              return $this->check_hint_file_access($qa, $options, $args);
 347  
 348          } else {
 349              return parent::check_file_access($qa, $options, $component, $filearea,
 350                      $args, $forcedownload);
 351          }
 352      }
 353  
 354      /**
 355       * Return the question settings that define this question as structured data.
 356       *
 357       * @param question_attempt $qa the current attempt for which we are exporting the settings.
 358       * @param question_display_options $options the question display options which say which aspects of the question
 359       * should be visible.
 360       * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
 361       */
 362      public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
 363          // This is a partial implementation, returning only the most relevant question settings for now,
 364          // ideally, we should return as much as settings as possible (depending on the state and display options).
 365  
 366          return [
 367              'shufflechoices' => $this->shufflechoices,
 368          ];
 369      }
 370  }