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 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   * Matching question definition class.
      19   *
      20   * @package   qtype_match
      21   * @copyright 2009 The Open University
      22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
      23   */
      24  
      25  
      26  defined('MOODLE_INTERNAL') || die();
      27  
      28  require_once($CFG->dirroot . '/question/type/questionbase.php');
      29  
      30  /**
      31   * Represents a matching question.
      32   *
      33   * @copyright 2009 The Open University
      34   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
      35   */
      36  class qtype_match_question extends question_graded_automatically_with_countback {
      37      /** @var boolean Whether the question stems should be shuffled. */
      38      public $shufflestems;
      39  
      40      public $correctfeedback;
      41      public $correctfeedbackformat;
      42      public $partiallycorrectfeedback;
      43      public $partiallycorrectfeedbackformat;
      44      public $incorrectfeedback;
      45      public $incorrectfeedbackformat;
      46  
      47      /** @var array of question stems. */
      48      public $stems;
      49      /** @var int[] FORMAT_... type for each stem. */
      50      public $stemformat;
      51      /** @var array of choices that can be matched to each stem. */
      52      public $choices;
      53      /** @var array index of the right choice for each stem. */
      54      public $right;
      55  
      56      /** @var array shuffled stem indexes. */
      57      protected $stemorder;
      58      /** @var array shuffled choice indexes. */
      59      protected $choiceorder;
      60  
      61      public function start_attempt(question_attempt_step $step, $variant) {
      62          $this->stemorder = array_keys($this->stems);
      63          if ($this->shufflestems) {
      64              shuffle($this->stemorder);
      65          }
      66          $step->set_qt_var('_stemorder', implode(',', $this->stemorder));
      67  
      68          $choiceorder = array_keys($this->choices);
      69          shuffle($choiceorder);
      70          $step->set_qt_var('_choiceorder', implode(',', $choiceorder));
      71          $this->set_choiceorder($choiceorder);
      72      }
      73  
      74      public function apply_attempt_state(question_attempt_step $step) {
      75          $this->stemorder = explode(',', $step->get_qt_var('_stemorder'));
      76          $this->set_choiceorder(explode(',', $step->get_qt_var('_choiceorder')));
      77  
      78          // Add any missing subquestions. Sometimes people edit questions after they
      79          // have been attempted which breaks things.
      80          foreach ($this->stemorder as $stemid) {
      81              if (!isset($this->stems[$stemid])) {
      82                  $this->stems[$stemid] = html_writer::span(
      83                          get_string('deletedsubquestion', 'qtype_match'), 'notifyproblem');
      84                  $this->stemformat[$stemid] = FORMAT_HTML;
      85                  $this->right[$stemid] = 0;
      86              }
      87          }
      88  
      89          // Add any missing choices. Sometimes people edit questions after they
      90          // have been attempted which breaks things.
      91          foreach ($this->choiceorder as $choiceid) {
      92              if (!isset($this->choices[$choiceid])) {
      93                  $this->choices[$choiceid] = get_string('deletedchoice', 'qtype_match');
      94              }
      95          }
      96      }
      97  
      98      /**
      99       * Helper method used by both {@link start_attempt()} and
     100       * {@link apply_attempt_state()}.
     101       * @param array $choiceorder the choices, in order.
     102       */
     103      protected function set_choiceorder($choiceorder) {
     104          $this->choiceorder = array();
     105          foreach ($choiceorder as $key => $choiceid) {
     106              $this->choiceorder[$key + 1] = $choiceid;
     107          }
     108      }
     109  
     110      public function get_question_summary() {
     111          $question = $this->html_to_text($this->questiontext, $this->questiontextformat);
     112          $stems = array();
     113          foreach ($this->stemorder as $stemid) {
     114              $stems[] = $this->html_to_text($this->stems[$stemid], $this->stemformat[$stemid]);
     115          }
     116          $choices = array();
     117          foreach ($this->choiceorder as $choiceid) {
     118              $choices[] = $this->choices[$choiceid];
     119          }
     120          return $question . ' {' . implode('; ', $stems) . '} -> {' .
     121                  implode('; ', $choices) . '}';
     122      }
     123  
     124      public function summarise_response(array $response) {
     125          $matches = array();
     126          foreach ($this->stemorder as $key => $stemid) {
     127              if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) {
     128                  $matches[] = $this->html_to_text($this->stems[$stemid],
     129                          $this->stemformat[$stemid]) . ' -> ' .
     130                          $this->choices[$this->choiceorder[$response[$this->field($key)]]];
     131              }
     132          }
     133          if (empty($matches)) {
     134              return null;
     135          }
     136          return implode('; ', $matches);
     137      }
     138  
     139      public function classify_response(array $response) {
     140          $selectedchoicekeys = array();
     141          foreach ($this->stemorder as $key => $stemid) {
     142              if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) {
     143                  $selectedchoicekeys[$stemid] = $this->choiceorder[$response[$this->field($key)]];
     144              } else {
     145                  $selectedchoicekeys[$stemid] = 0;
     146              }
     147          }
     148  
     149          $parts = array();
     150          foreach ($this->stems as $stemid => $stem) {
     151              if ($this->right[$stemid] == 0 || !isset($selectedchoicekeys[$stemid])) {
     152                  // Choice for a deleted subquestion, ignore. (See apply_attempt_state.)
     153                  continue;
     154              }
     155              $selectedchoicekey = $selectedchoicekeys[$stemid];
     156              if (empty($selectedchoicekey)) {
     157                  $parts[$stemid] = question_classified_response::no_response();
     158                  continue;
     159              }
     160              $choice = $this->choices[$selectedchoicekey];
     161              if ($choice == get_string('deletedchoice', 'qtype_match')) {
     162                  // Deleted choice, ignore. (See apply_attempt_state.)
     163                  continue;
     164              }
     165              $parts[$stemid] = new question_classified_response(
     166                      $selectedchoicekey, $choice,
     167                      ($selectedchoicekey == $this->right[$stemid]) / count($this->stems));
     168          }
     169          return $parts;
     170      }
     171  
     172      public function clear_wrong_from_response(array $response) {
     173          foreach ($this->stemorder as $key => $stemid) {
     174              if (!array_key_exists($this->field($key), $response) ||
     175                      $response[$this->field($key)] != $this->get_right_choice_for($stemid)) {
     176                  $response[$this->field($key)] = 0;
     177              }
     178          }
     179          return $response;
     180      }
     181  
     182      public function get_num_parts_right(array $response) {
     183          $numright = 0;
     184          foreach ($this->stemorder as $key => $stemid) {
     185              $fieldname = $this->field($key);
     186              if (!array_key_exists($fieldname, $response)) {
     187                  continue;
     188              }
     189  
     190              $choice = $response[$fieldname];
     191              if ($choice && $this->choiceorder[$choice] == $this->right[$stemid]) {
     192                  $numright += 1;
     193              }
     194          }
     195          return array($numright, count($this->stemorder));
     196      }
     197  
     198      /**
     199       * @param int $key stem number
     200       * @return string the question-type variable name.
     201       */
     202      protected function field($key) {
     203          return 'sub' . $key;
     204      }
     205  
     206      public function get_expected_data() {
     207          $vars = array();
     208          foreach ($this->stemorder as $key => $notused) {
     209              $vars[$this->field($key)] = PARAM_INT;
     210          }
     211          return $vars;
     212      }
     213  
     214      public function get_correct_response() {
     215          $response = array();
     216          foreach ($this->stemorder as $key => $stemid) {
     217              $response[$this->field($key)] = $this->get_right_choice_for($stemid);
     218          }
     219          return $response;
     220      }
     221  
     222      public function prepare_simulated_post_data($simulatedresponse) {
     223          $postdata = array();
     224          $stemtostemids = array_flip(clean_param_array($this->stems, PARAM_NOTAGS));
     225          $choicetochoiceno = array_flip($this->choices);
     226          $choicenotochoiceselectvalue = array_flip($this->choiceorder);
     227          foreach ($simulatedresponse as $stem => $choice) {
     228              $choice = clean_param($choice, PARAM_NOTAGS);
     229              $stemid = $stemtostemids[$stem];
     230              $shuffledstemno = array_search($stemid, $this->stemorder);
     231              if (empty($choice)) {
     232                  $choiceselectvalue = 0;
     233              } else if ($choicetochoiceno[$choice]) {
     234                  $choiceselectvalue = $choicenotochoiceselectvalue[$choicetochoiceno[$choice]];
     235              } else {
     236                  throw new coding_exception("Unknown choice {$choice} in matching question - {$this->name}.");
     237              }
     238              $postdata[$this->field($shuffledstemno)] = $choiceselectvalue;
     239          }
     240          return $postdata;
     241      }
     242  
     243      public function get_student_response_values_for_simulation($postdata) {
     244          $simulatedresponse = array();
     245          foreach ($this->stemorder as $shuffledstemno => $stemid) {
     246              if (!empty($postdata[$this->field($shuffledstemno)])) {
     247                  $choiceselectvalue = $postdata[$this->field($shuffledstemno)];
     248                  $choiceno = $this->choiceorder[$choiceselectvalue];
     249                  $choice = clean_param($this->choices[$choiceno], PARAM_NOTAGS);
     250                  $stem = clean_param($this->stems[$stemid], PARAM_NOTAGS);
     251                  $simulatedresponse[$stem] = $choice;
     252              }
     253          }
     254          ksort($simulatedresponse);
     255          return $simulatedresponse;
     256      }
     257  
     258      public function get_right_choice_for($stemid) {
     259          foreach ($this->choiceorder as $choicekey => $choiceid) {
     260              if ($this->right[$stemid] == $choiceid) {
     261                  return $choicekey;
     262              }
     263          }
     264      }
     265  
     266      public function is_complete_response(array $response) {
     267          $complete = true;
     268          foreach ($this->stemorder as $key => $stemid) {
     269              $complete = $complete && !empty($response[$this->field($key)]);
     270          }
     271          return $complete;
     272      }
     273  
     274      public function is_gradable_response(array $response) {
     275          foreach ($this->stemorder as $key => $stemid) {
     276              if (!empty($response[$this->field($key)])) {
     277                  return true;
     278              }
     279          }
     280          return false;
     281      }
     282  
     283      public function get_validation_error(array $response) {
     284          if ($this->is_complete_response($response)) {
     285              return '';
     286          }
     287          return get_string('pleaseananswerallparts', 'qtype_match');
     288      }
     289  
     290      public function is_same_response(array $prevresponse, array $newresponse) {
     291          foreach ($this->stemorder as $key => $notused) {
     292              $fieldname = $this->field($key);
     293              if (!question_utils::arrays_same_at_key_integer(
     294                      $prevresponse, $newresponse, $fieldname)) {
     295                  return false;
     296              }
     297          }
     298          return true;
     299      }
     300  
     301      public function grade_response(array $response) {
     302          list($right, $total) = $this->get_num_parts_right($response);
     303          $fraction = $right / $total;
     304          return array($fraction, question_state::graded_state_for_fraction($fraction));
     305      }
     306  
     307      public function compute_final_grade($responses, $totaltries) {
     308          $totalstemscore = 0;
     309          foreach ($this->stemorder as $key => $stemid) {
     310              $fieldname = $this->field($key);
     311  
     312              $lastwrongindex = -1;
     313              $finallyright = false;
     314              foreach ($responses as $i => $response) {
     315                  if (!array_key_exists($fieldname, $response) || !$response[$fieldname] ||
     316                          $this->choiceorder[$response[$fieldname]] != $this->right[$stemid]) {
     317                      $lastwrongindex = $i;
     318                      $finallyright = false;
     319                  } else {
     320                      $finallyright = true;
     321                  }
     322              }
     323  
     324              if ($finallyright) {
     325                  $totalstemscore += max(0, 1 - ($lastwrongindex + 1) * $this->penalty);
     326              }
     327          }
     328  
     329          return $totalstemscore / count($this->stemorder);
     330      }
     331  
     332      public function get_stem_order() {
     333          return $this->stemorder;
     334      }
     335  
     336      public function get_choice_order() {
     337          return $this->choiceorder;
     338      }
     339  
     340      public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
     341          if ($component == 'qtype_match' && $filearea == 'subquestion') {
     342              $subqid = reset($args); // Itemid is sub question id.
     343              return array_key_exists($subqid, $this->stems);
     344  
     345          } else if ($component == 'question' && in_array($filearea,
     346                  array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) {
     347              return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
     348  
     349          } else if ($component == 'question' && $filearea == 'hint') {
     350              return $this->check_hint_file_access($qa, $options, $args);
     351  
     352          } else {
     353              return parent::check_file_access($qa, $options, $component, $filearea,
     354                      $args, $forcedownload);
     355          }
     356      }
     357  
     358      /**
     359       * Return the question settings that define this question as structured data.
     360       *
     361       * @param question_attempt $qa the current attempt for which we are exporting the settings.
     362       * @param question_display_options $options the question display options which say which aspects of the question
     363       * should be visible.
     364       * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
     365       */
     366      public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
     367          // This is a partial implementation, returning only the most relevant question settings for now,
     368          // ideally, we should return as much as settings as possible (depending on the state and display options).
     369  
     370          return [
     371              'shufflestems' => $this->shufflestems,
     372          ];
     373      }
     374  }