PageRenderTime 46ms CodeModel.GetById 15ms RepoModel.GetById 1ms app.codeStats 0ms

/question/type/match/question.php

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