MDL-79875 qtype_ordering: Template to output grade detail
[moodle.git] / question / type / ordering / question.php
blobf7b0a288708f68fcfdf7aecbb438197f3831e71a
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.
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/>.
17 /**
18 * Ordering question definition classes.
20 * @package qtype_ordering
22 * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com)
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26 // Prevent direct access to this script.
28 /**
29 * Represents an ordering question.
31 * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com)
32 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34 class qtype_ordering_question extends question_graded_automatically {
36 /** Select all answers */
37 const SELECT_ALL = 0;
38 /** Select random set of answers */
39 const SELECT_RANDOM = 1;
40 /** Select contiguous subset of answers */
41 const SELECT_CONTIGUOUS = 2;
43 /** Show answers in vertical list */
44 const LAYOUT_VERTICAL = 0;
45 /** Show answers in one horizontal line */
46 const LAYOUT_HORIZONTAL = 1;
48 /** Default value for numberingstyle */
49 const NUMBERING_STYLE_DEFAULT = 'none';
51 /** @var int Zero grade on any error */
52 const GRADING_ALL_OR_NOTHING = -1;
53 /** @var int Counts items, placed into right absolute place */
54 const GRADING_ABSOLUTE_POSITION = 0;
55 /** @var int Every sequential pair in right order is graded (last pair is excluded) */
56 const GRADING_RELATIVE_NEXT_EXCLUDE_LAST = 1;
57 /** @var int Every sequential pair in right order is graded (last pair is included) */
58 const GRADING_RELATIVE_NEXT_INCLUDE_LAST = 2;
59 /** @var int Single answers that are placed before and after each answer is graded if in right order*/
60 const GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT = 3;
61 /** @var int All answers that are placed before and after each answer is graded if in right order*/
62 const GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT = 4;
63 /** @var int Only longest ordered subset is graded */
64 const GRADING_LONGEST_ORDERED_SUBSET = 5;
65 /** @var int Only longest ordered and contiguous subset is graded */
66 const GRADING_LONGEST_CONTIGUOUS_SUBSET = 6;
67 /** @var int Items are graded relative to their position in the correct answer */
68 const GRADING_RELATIVE_TO_CORRECT = 7;
70 // Fields from "qtype_ordering_options" table.
71 /** @var string */
72 public $correctfeedback;
73 /** @var int */
74 public $correctfeedbackformat;
75 /** @var string */
76 public $incorrectfeedback;
77 /** @var int */
78 public $incorrectfeedbackformat;
79 /** @var string */
80 public $partiallycorrectfeedback;
81 /** @var int */
82 public $partiallycorrectfeedbackformat;
84 /** @var array Records from "question_answers" table */
85 public $answers;
87 /** @var stdClass Records from "qtype_ordering_options" table */
88 public $options;
90 /** @var array of answerids in correct order */
91 public $correctresponse;
93 /** @var array contatining current order of answerids */
94 public $currentresponse;
96 /** @var array of scored for every item */
97 protected $itemscores = [];
99 /**
100 * Start a new attempt at this question, storing any information that will
101 * be needed later in the step.
103 * This is where the question can do any initialisation required on a
104 * per-attempt basis. For example, this is where the multiple choice
105 * question type randomly shuffles the choices (if that option is set).
107 * Any information about how the question has been set up for this attempt
108 * should be stored in the $step, by calling $step->set_qt_var(...).
110 * @param question_attempt_step $step The first step of the {@see question_attempt}
111 * being started. Can be used to store state.
112 * @param int $variant which variant of this question to start. Will be between
113 * 1 and {@see get_num_variants()} inclusive.
115 public function start_attempt(question_attempt_step $step, $variant) {
116 $countanswers = count($this->answers);
118 // Sanitize "selecttype".
119 $selecttype = $this->options->selecttype;
120 $selecttype = max(0, $selecttype);
121 $selecttype = min(2, $selecttype);
123 // Sanitize "selectcount".
124 $selectcount = $this->options->selectcount;
125 $selectcount = max(3, $selectcount);
126 $selectcount = min($countanswers, $selectcount);
128 // Ensure consistency between "selecttype" and "selectcount".
129 switch (true) {
130 case ($selecttype == self::SELECT_ALL):
131 $selectcount = $countanswers;
132 break;
133 case ($selectcount == $countanswers):
134 $selecttype = self::SELECT_ALL;
135 break;
138 // Extract answer ids.
139 switch ($selecttype) {
140 case self::SELECT_ALL:
141 $answerids = array_keys($this->answers);
142 break;
144 case self::SELECT_RANDOM:
145 $answerids = array_rand($this->answers, $selectcount);
146 break;
148 case self::SELECT_CONTIGUOUS:
149 $answerids = array_keys($this->answers);
150 $offset = mt_rand(0, $countanswers - $selectcount);
151 $answerids = array_slice($answerids, $offset, $selectcount);
152 break;
155 $this->correctresponse = $answerids;
156 $step->set_qt_var('_correctresponse', implode(',', $this->correctresponse));
158 shuffle($answerids);
159 $this->currentresponse = $answerids;
160 $step->set_qt_var('_currentresponse', implode(',', $this->currentresponse));
164 * When an in-progress {@see question_attempt} is re-loaded from the
165 * database, this method is called so that the question can re-initialise
166 * its internal state as needed by this attempt.
168 * For example, the multiple choice question type needs to set the order
169 * of the choices to the order that was set up when start_attempt was called
170 * originally. All the information required to do this should be in the
171 * $step object, which is the first step of the question_attempt being loaded.
173 * @param question_attempt_step $step The first step of the {@see question_attempt}
174 * being loaded.
176 public function apply_attempt_state(question_attempt_step $step) {
177 $this->currentresponse = array_filter(explode(',', $step->get_qt_var('_currentresponse')));
178 $this->correctresponse = array_filter(explode(',', $step->get_qt_var('_correctresponse')));
181 public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
182 $basemessage = parent::validate_can_regrade_with_other_version($otherversion);
183 if ($basemessage) {
184 return $basemessage;
187 if (count($this->answers) != count($otherversion->answers)) {
188 return get_string('regradeissuenumitemschanged', 'qtype_ordering');
191 return null;
194 public function update_attempt_state_data_for_new_version(
195 question_attempt_step $oldstep, question_definition $otherversion) {
196 parent::update_attempt_state_data_for_new_version($oldstep, $otherversion);
198 $mapping = array_combine(array_keys($otherversion->answers), array_keys($this->answers));
200 $oldorder = explode(',', $oldstep->get_qt_var('_currentresponse'));
201 $neworder = [];
202 foreach ($oldorder as $oldid) {
203 $neworder[] = $mapping[$oldid] ?? $oldid;
206 $oldcorrect = explode(',', $oldstep->get_qt_var('_correctresponse'));
207 $newcorrect = [];
208 foreach ($oldcorrect as $oldid) {
209 $newcorrect[] = $mapping[$oldid] ?? $oldid;
212 return [
213 '_currentresponse' => implode(',', $neworder),
214 '_correctresponse' => implode(',', $newcorrect),
219 * What data may be included in the form submission when a student submits
220 * this question in its current state?
222 * This information is used in calls to optional_param. The parameter name
223 * has {@see question_attempt::get_field_prefix()} automatically prepended.
225 * @return array|string variable name => PARAM_... constant, or, as a special case
226 * that should only be used in unavoidable, the constant question_attempt::USE_RAW_DATA
227 * meaning take all the raw submitted data belonging to this question.
229 public function get_expected_data() {
230 $name = $this->get_response_fieldname();
231 return [$name => PARAM_TEXT];
235 * What data would need to be submitted to get this question correct.
236 * If there is more than one correct answer, this method should just
237 * return one possibility. If it is not possible to compute a correct
238 * response, this method should return null.
240 * @return array|null parameter name => value.
242 public function get_correct_response() {
243 $correctresponse = $this->correctresponse;
244 foreach ($correctresponse as $position => $answerid) {
245 $answer = $this->answers[$answerid];
246 $correctresponse[$position] = $answer->md5key;
248 $name = $this->get_response_fieldname();
249 return [$name => implode(',', $correctresponse)];
253 * Produce a plain text summary of a response.
255 * @param array $response a response, as might be passed to {@see grade_response()}.
256 * @return string a plain text summary of that response, that could be used in reports.
258 public function summarise_response(array $response) {
259 $name = $this->get_response_fieldname();
260 if (array_key_exists($name, $response)) {
261 $items = explode(',', $response[$name]);
262 } else {
263 $items = []; // Shouldn't happen!
265 $answerids = [];
266 foreach ($this->answers as $answer) {
267 $answerids[$answer->md5key] = $answer->id;
269 foreach ($items as $i => $item) {
270 if (array_key_exists($item, $answerids)) {
271 $item = $this->answers[$answerids[$item]];
272 $item = $this->html_to_text($item->answer, $item->answerformat);
273 $item = shorten_text($item, 10, true); // Force truncate at 10 chars.
274 $items[$i] = $item;
275 } else {
276 $items[$i] = ''; // Shouldn't happen!
279 return implode('; ', array_filter($items));
283 * Categorise the student's response according to the categories defined by
284 * get_possible_responses.
286 * @param array $response a response, as might be passed to {@see grade_response()}.
287 * @return array subpartid => {@see question_classified_response} objects.
288 * returns an empty array if no analysis is possible.
290 public function classify_response(array $response) {
291 $this->update_current_response($response);
292 $fraction = 1 / count($this->correctresponse);
294 $classifiedresponse = [];
295 foreach ($this->correctresponse as $position => $answerid) {
296 if (in_array($answerid, $this->currentresponse)) {
297 $currentposition = array_search($answerid, $this->currentresponse);
300 $answer = $this->answers[$answerid];
301 $subqid = question_utils::to_plain_text($answer->answer, $answer->answerformat);
303 // Truncate responses longer than 100 bytes because they cannot be stored in the database.
304 // CAUTION: This will mess up answers which are not unique within the first 100 chars!
305 $maxbytes = 100;
306 if (strlen($subqid) > $maxbytes) {
307 // If the truncation point is in the middle of a multi-byte unicode char,
308 // we remove the incomplete part with a preg_match() that is unicode aware.
309 $subqid = substr($subqid, 0, $maxbytes);
310 if (preg_match('/^(.|\n)*/u', '', $subqid, $match)) {
311 $subqid = $match[0];
315 $classifiedresponse[$subqid] = new question_classified_response(
316 $currentposition + 1,
317 get_string('positionx', 'qtype_ordering', $currentposition + 1),
318 ($currentposition == $position) * $fraction
322 return $classifiedresponse;
326 * Used by many of the behaviours, to work out whether the student's
327 * response to the question is complete. That is, whether the question attempt
328 * should move to the COMPLETE or INCOMPLETE state.
330 * @param array $response responses, as returned by
331 * {@see question_attempt_step::get_qt_data()}.
332 * @return bool whether this response is a complete answer to this question.
334 public function is_complete_response(array $response) {
335 return true;
339 * Use by many of the behaviours to determine whether the student
340 * has provided enough of an answer for the question to be graded automatically,
341 * or whether it must be considered aborted.
343 * @param array $response responses, as returned by
344 * {@see question_attempt_step::get_qt_data()}.
345 * @return bool whether this response can be graded.
347 public function is_gradable_response(array $response) {
348 return true;
352 * In situations where is_gradable_response() returns false, this method
353 * should generate a description of what the problem is.
355 * @param array $response
356 * @return string the message
358 public function get_validation_error(array $response) {
359 return '';
363 * Use by many of the behaviours to determine whether the student's
364 * response has changed. This is normally used to determine that a new set
365 * of responses can safely be discarded.
367 * @param array $old the responses previously recorded for this question,
368 * as returned by {@see question_attempt_step::get_qt_data()}
369 * @param array $new the new responses, in the same format.
370 * @return bool whether the two sets of responses are the same - that is
371 * whether the new set of responses can safely be discarded.
373 public function is_same_response(array $old, array $new) {
374 $name = $this->get_response_fieldname();
375 return (isset($old[$name]) && isset($new[$name]) && $old[$name] == $new[$name]);
379 * Grade a response to the question, returning a fraction between
380 * get_min_fraction() and get_max_fraction(), and the corresponding {@see question_state}
381 * right, partial or wrong.
383 * @param array $response responses, as returned by
384 * {@see question_attempt_step::get_qt_data()}.
385 * @return array (float, integer) the fraction, and the state.
387 public function grade_response(array $response) {
388 $this->update_current_response($response);
390 $countcorrect = 0;
391 $countanswers = 0;
393 $gradingtype = $this->options->gradingtype;
394 switch ($gradingtype) {
396 case self::GRADING_ALL_OR_NOTHING:
397 case self::GRADING_ABSOLUTE_POSITION:
398 $correctresponse = $this->correctresponse;
399 $currentresponse = $this->currentresponse;
400 foreach ($correctresponse as $position => $answerid) {
401 if (array_key_exists($position, $currentresponse)) {
402 if ($currentresponse[$position] == $answerid) {
403 $countcorrect++;
406 $countanswers++;
408 if ($gradingtype == self::GRADING_ALL_OR_NOTHING && $countcorrect < $countanswers) {
409 $countcorrect = 0;
411 break;
413 case self::GRADING_RELATIVE_NEXT_EXCLUDE_LAST:
414 case self::GRADING_RELATIVE_NEXT_INCLUDE_LAST:
415 $lastitem = ($gradingtype == self::GRADING_RELATIVE_NEXT_INCLUDE_LAST);
416 $currentresponse = $this->get_next_answerids($this->currentresponse, $lastitem);
417 $correctresponse = $this->get_next_answerids($this->correctresponse, $lastitem);
418 foreach ($correctresponse as $thisanswerid => $nextanswerid) {
419 if (array_key_exists($thisanswerid, $currentresponse)) {
420 if ($currentresponse[$thisanswerid] == $nextanswerid) {
421 $countcorrect++;
424 $countanswers++;
426 break;
428 case self::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT:
429 case self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT:
430 $all = ($gradingtype == self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT);
431 $currentresponse = $this->get_previous_and_next_answerids($this->currentresponse, $all);
432 $correctresponse = $this->get_previous_and_next_answerids($this->correctresponse, $all);
433 foreach ($correctresponse as $thisanswerid => $answerids) {
434 if (array_key_exists($thisanswerid, $currentresponse)) {
435 $prev = $currentresponse[$thisanswerid]->prev;
436 $prev = array_intersect($prev, $answerids->prev);
437 $countcorrect += count($prev);
438 $next = $currentresponse[$thisanswerid]->next;
439 $next = array_intersect($next, $answerids->next);
440 $countcorrect += count($next);
442 $countanswers += count($answerids->prev);
443 $countanswers += count($answerids->next);
445 break;
447 case self::GRADING_LONGEST_ORDERED_SUBSET:
448 case self::GRADING_LONGEST_CONTIGUOUS_SUBSET:
449 $contiguous = ($gradingtype == self::GRADING_LONGEST_CONTIGUOUS_SUBSET);
450 $subset = $this->get_ordered_subset($contiguous);
451 $countcorrect = count($subset);
452 $countanswers = count($this->currentresponse);
453 break;
455 case self::GRADING_RELATIVE_TO_CORRECT:
456 $correctresponse = $this->correctresponse;
457 $currentresponse = $this->currentresponse;
458 $count = (count($correctresponse) - 1);
459 foreach ($correctresponse as $position => $answerid) {
460 if (in_array($answerid, $currentresponse)) {
461 $currentposition = array_search($answerid, $currentresponse);
462 $currentscore = ($count - abs($position - $currentposition));
463 if ($currentscore > 0) {
464 $countcorrect += $currentscore;
467 $countanswers += $count;
469 break;
471 if ($countanswers == 0) {
472 $fraction = 0;
473 } else {
474 $fraction = ($countcorrect / $countanswers);
476 return [
477 $fraction,
478 question_state::graded_state_for_fraction($fraction),
483 * Checks whether the user has permission to access a particular file.
485 * @param question_attempt $qa the question attempt being displayed.
486 * @param question_display_options $options the options that control display of the question.
487 * @param string $component the name of the component we are serving files for.
488 * @param string $filearea the name of the file area.
489 * @param array $args the remaining bits of the file path.
490 * @param bool $forcedownload whether the user must be forced to download the file.
491 * @return bool true if the user can access this file.
493 public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
494 if ($component == 'question') {
495 if ($filearea == 'answer') {
496 $answerid = reset($args); // Value of "itemid" is answer id.
497 return array_key_exists($answerid, $this->answers);
499 if (in_array($filearea, $this->qtype->feedbackfields)) {
500 return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
502 if ($filearea == 'hint') {
503 return $this->check_hint_file_access($qa, $options, $args);
506 return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload);
509 // Methods from "question_graded_automatically" class.
510 // See "question/type/questionbase.php".
513 * Check a request for access to a file belonging to a combined feedback field.
515 * Fix a bug in Moodle 2.9 & 3.0, in which this method does not declare $args,
516 * so trying to use $args[0] always fails and images in feedback are not shown.
518 * @param question_attempt $qa the question attempt being displayed.
519 * @param question_display_options $options the options that control display of the question.
520 * @param string $filearea the name of the file area.
521 * @param array $args the remaining bits of the file path.
522 * @return bool whether access to the file should be allowed.
524 protected function check_combined_feedback_file_access($qa, $options, $filearea, $args = null) {
525 $state = $qa->get_state();
526 if (! $state->is_finished()) {
527 $response = $qa->get_last_qt_data();
528 if (! $this->is_gradable_response($response)) {
529 return false;
531 list($fraction, $state) = $this->grade_response($response);
533 if ($state->get_feedback_class().'feedback' == $filearea) {
534 return ($this->id == reset($args));
535 } else {
536 return false;
540 // Custom methods.
543 * Returns response mform field name
545 * @return string
547 public function get_response_fieldname(): string {
548 return 'response_' . $this->id;
552 * Convert response data from mform into array
554 * @param array $response Form data
556 public function update_current_response(array $response) {
557 $name = $this->get_response_fieldname();
558 if (array_key_exists($name, $response)) {
559 $ids = explode(',', $response[$name]);
560 foreach ($ids as $i => $id) {
561 foreach ($this->answers as $answer) {
562 if ($id == $answer->md5key) {
563 $ids[$i] = $answer->id;
564 break;
568 $this->currentresponse = $ids;
573 * Returns layoutclass
575 * @return string
577 public function get_ordering_layoutclass(): string {
578 switch ($this->options->layouttype) {
579 case self::LAYOUT_VERTICAL:
580 return 'vertical';
581 case self::LAYOUT_HORIZONTAL:
582 return 'horizontal';
583 default:
584 return ''; // Shouldn't happen!
589 * Returns array of next answers
591 * @param array $answerids array of answers id
592 * @param bool $lastitem Include last item?
593 * @return array of id of next answer
595 public function get_next_answerids(array $answerids, bool $lastitem = false): array {
596 $nextanswerids = [];
597 $imax = count($answerids);
598 $imax--;
599 if ($lastitem) {
600 $nextanswerid = 0;
601 } else {
602 $nextanswerid = $answerids[$imax];
603 $imax--;
605 for ($i = $imax; $i >= 0; $i--) {
606 $thisanswerid = $answerids[$i];
607 $nextanswerids[$thisanswerid] = $nextanswerid;
608 $nextanswerid = $thisanswerid;
610 return $nextanswerids;
614 * Returns prev and next answers array
616 * @param array $answerids array of answers id
617 * @param bool $all include all answers
618 * @return array of array('prev' => previd, 'next' => nextid)
620 public function get_previous_and_next_answerids(array $answerids, bool $all = false): array {
621 $prevnextanswerids = [];
622 $next = $answerids;
623 $prev = [];
624 while ($answerid = array_shift($next)) {
625 if ($all) {
626 $prevnextanswerids[$answerid] = (object) [
627 'prev' => $prev,
628 'next' => $next,
630 } else {
631 $prevnextanswerids[$answerid] = (object) [
632 'prev' => [empty($prev) ? 0 : $prev[0]],
633 'next' => [empty($next) ? 0 : $next[0]],
636 array_unshift($prev, $answerid);
638 return $prevnextanswerids;
642 * Search for best ordered subset
644 * @param bool $contiguous A flag indicating whether only contiguous values should be considered for inclusion in the subset.
645 * @return array
647 public function get_ordered_subset(bool $contiguous): array {
649 $positions = $this->get_ordered_positions($this->correctresponse, $this->currentresponse);
650 $subsets = $this->get_ordered_subsets($positions, $contiguous);
652 // The best subset (longest and leftmost).
653 $bestsubset = [];
655 // The length of the best subset
656 // initializing this to 1 means
657 // we ignore single item subsets.
658 $bestcount = 1;
660 foreach ($subsets as $subset) {
661 $count = count($subset);
662 if ($count > $bestcount) {
663 $bestcount = $count;
664 $bestsubset = $subset;
667 return $bestsubset;
671 * Get array of right answer positions for current response
673 * @param array $correctresponse
674 * @param array $currentresponse
675 * @return array
677 public function get_ordered_positions(array $correctresponse, array $currentresponse): array {
678 $positions = [];
679 foreach ($currentresponse as $answerid) {
680 $positions[] = array_search($answerid, $correctresponse);
682 return $positions;
686 * Get all ordered subsets in the positions array
688 * @param array $positions maps an item's current position to its correct position
689 * @param bool $contiguous TRUE if searching only for contiguous subsets; otherwise FALSE
690 * @return array of ordered subsets from within the $positions array
692 public function get_ordered_subsets(array $positions, bool $contiguous): array {
694 // Var $subsets is the collection of all subsets within $positions.
695 $subsets = [];
697 // Loop through the values at each position.
698 foreach ($positions as $p => $value) {
700 // Is $value a "new" value that cannot be added to any $subsets found so far?
701 $isnew = true;
703 // An array of new and saved subsets to be added to $subsets.
704 $new = [];
706 // Append the current value to any subsets to which it belongs
707 // i.e. any subset whose end value is less than the current value.
708 foreach ($subsets as $s => $subset) {
710 // Get value at end of $subset.
711 $end = $positions[end($subset)];
713 switch (true) {
715 case ($value == ($end + 1)):
716 // For a contiguous value, we simply append $p to the subset.
717 $isnew = false;
718 $subsets[$s][] = $p;
719 break;
721 case $contiguous:
722 // If the $contiguous flag is set, we ignore non-contiguous values.
723 break;
725 case ($value > $end):
726 // For a non-contiguous value, we save the subset so far,
727 // because a value between $end and $value may be found later,
728 // and then append $p to the subset.
729 $isnew = false;
730 $new[] = $subset;
731 $subsets[$s][] = $p;
732 break;
736 // If this is a "new" value, add it as a new subset.
737 if ($isnew) {
738 $new[] = [$p];
741 // Append any "new" subsets that were found during this iteration.
742 if (count($new)) {
743 $subsets = array_merge($subsets, $new);
747 return $subsets;
751 * Helper function for get_select_types, get_layout_types, get_grading_types
753 * @param array $types
754 * @param int $type
755 * @return array|string array if $type is not specified and single string if $type is specified
756 * @throws coding_exception
758 public static function get_types(array $types, $type): array|string {
759 if ($type === null) {
760 return $types; // Return all $types.
762 if (array_key_exists($type, $types)) {
763 return $types[$type]; // One $type.
766 throw new coding_exception('Invalid type: ' . $type);
770 * Returns available values and descriptions for field "selecttype"
772 * @param int|null $type
773 * @return array|string array if $type is not specified and single string if $type is specified
775 public static function get_select_types(int $type = null): array|string {
776 $plugin = 'qtype_ordering';
777 $types = [
778 self::SELECT_ALL => get_string('selectall', $plugin),
779 self::SELECT_RANDOM => get_string('selectrandom', $plugin),
780 self::SELECT_CONTIGUOUS => get_string('selectcontiguous', $plugin),
782 return self::get_types($types, $type);
786 * Returns available values and descriptions for field "layouttype"
788 * @param int|null $type
789 * @return array|string array if $type is not specified and single string if $type is specified
791 public static function get_layout_types(int $type = null): array|string {
792 $plugin = 'qtype_ordering';
793 $types = [
794 self::LAYOUT_VERTICAL => get_string('vertical', $plugin),
795 self::LAYOUT_HORIZONTAL => get_string('horizontal', $plugin),
797 return self::get_types($types, $type);
801 * Returns available values and descriptions for field "gradingtype"
803 * @param int|null $type
804 * @return array|string array if $type is not specified and single string if $type is specified
806 public static function get_grading_types(int $type = null): array|string {
807 $plugin = 'qtype_ordering';
808 $types = [
809 self::GRADING_ALL_OR_NOTHING => get_string('allornothing', $plugin),
810 self::GRADING_ABSOLUTE_POSITION => get_string('absoluteposition', $plugin),
811 self::GRADING_RELATIVE_TO_CORRECT => get_string('relativetocorrect', $plugin),
812 self::GRADING_RELATIVE_NEXT_EXCLUDE_LAST => get_string('relativenextexcludelast', $plugin),
813 self::GRADING_RELATIVE_NEXT_INCLUDE_LAST => get_string('relativenextincludelast', $plugin),
814 self::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT => get_string('relativeonepreviousandnext', $plugin),
815 self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT => get_string('relativeallpreviousandnext', $plugin),
816 self::GRADING_LONGEST_ORDERED_SUBSET => get_string('longestorderedsubset', $plugin),
817 self::GRADING_LONGEST_CONTIGUOUS_SUBSET => get_string('longestcontiguoussubset', $plugin),
819 return self::get_types($types, $type);
823 * Get the numbering styles supported.
825 * For each style, there should be a corresponding lang string 'numberingstylexxx' in the qtype_ordering language file,
826 * a case in the switch statement in number_in_style, and it should be listed in the definition of this column in install.xml.
828 * @param string|null $style The specific numbering style to retrieve.
829 * @return array|string Numbering style(s).
830 * The keys are style identifiers, and the values are the corresponding language strings.
832 public static function get_numbering_styles(string $style = null): array|string {
833 $plugin = 'qtype_ordering';
834 $styles = [
835 'none' => get_string('numberingstylenone', $plugin),
836 'abc' => get_string('numberingstyleabc', $plugin),
837 'ABCD' => get_string('numberingstyleABCD', $plugin),
838 '123' => get_string('numberingstyle123', $plugin),
839 'iii' => get_string('numberingstyleiii', $plugin),
840 'IIII' => get_string('numberingstyleIIII', $plugin),
842 return self::get_types($styles, $style);
846 * Return the number of subparts of this response that are correct|partial|incorrect.
848 * @param array $response A response.
849 * @return array Array of three elements: the number of correct subparts,
850 * the number of partial correct subparts and the number of incorrect subparts.
852 public function get_num_parts_right(array $response): array {
853 $this->update_current_response($response);
854 $gradingtype = $this->options->gradingtype;
856 $numright = 0;
857 $numpartial = 0;
858 $numincorrect = 0;
859 list($correctresponse, $currentresponse) = $this->get_response_depend_on_grading_type($gradingtype);
861 foreach ($this->currentresponse as $position => $answerid) {
862 [$fraction, $score, $maxscore] =
863 $this->get_fraction_maxscore_score_of_item($position, $answerid, $correctresponse, $currentresponse);
864 if (is_null($fraction)) {
865 continue;
868 if ($fraction > 0.999999) {
869 $numright++;
870 } else if ($fraction < 0.000001) {
871 $numincorrect++;
872 } else {
873 $numpartial++;
877 return [$numright, $numpartial, $numincorrect];
881 * Returns the grade for one item, base on the fraction scale.
883 * @param int $position The position of the current response.
884 * @param int $answerid The answerid of the current response.
885 * @param array $correctresponse The correct response list base on grading type.
886 * @param array $currentresponse The current response list base on grading type.
887 * @return array.
889 protected function get_fraction_maxscore_score_of_item(
890 int $position,
891 int $answerid,
892 array $correctresponse,
893 array $currentresponse
894 ): array {
895 $gradingtype = $this->options->gradingtype;
897 $score = 0;
898 $maxscore = null;
900 switch ($gradingtype) {
901 case self::GRADING_ALL_OR_NOTHING:
902 case self::GRADING_ABSOLUTE_POSITION:
903 if (isset($correctresponse[$position])) {
904 if ($correctresponse[$position] == $answerid) {
905 $score = 1;
907 $maxscore = 1;
909 break;
910 case self::GRADING_RELATIVE_NEXT_EXCLUDE_LAST:
911 case self::GRADING_RELATIVE_NEXT_INCLUDE_LAST:
912 if (isset($correctresponse[$answerid])) {
913 if (isset($currentresponse[$answerid]) && $currentresponse[$answerid] == $correctresponse[$answerid]) {
914 $score = 1;
916 $maxscore = 1;
918 break;
920 case self::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT:
921 case self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT:
922 if (isset($correctresponse[$answerid])) {
923 $maxscore = 0;
924 $prev = $correctresponse[$answerid]->prev;
925 $maxscore += count($prev);
926 $prev = array_intersect($prev, $currentresponse[$answerid]->prev);
927 $score += count($prev);
928 $next = $correctresponse[$answerid]->next;
929 $maxscore += count($next);
930 $next = array_intersect($next, $currentresponse[$answerid]->next);
931 $score += count($next);
933 break;
935 case self::GRADING_LONGEST_ORDERED_SUBSET:
936 case self::GRADING_LONGEST_CONTIGUOUS_SUBSET:
937 if (isset($correctresponse[$position])) {
938 if (isset($currentresponse[$position])) {
939 $score = $currentresponse[$position];
941 $maxscore = 1;
943 break;
945 case self::GRADING_RELATIVE_TO_CORRECT:
946 if (isset($correctresponse[$position])) {
947 $maxscore = (count($correctresponse) - 1);
948 $answerid = $currentresponse[$position];
949 $correctposition = array_search($answerid, $correctresponse);
950 $score = ($maxscore - abs($correctposition - $position));
951 if ($score < 0) {
952 $score = 0;
955 break;
957 $fraction = $maxscore ? $score / $maxscore : $maxscore;
959 return [$fraction, $score, $maxscore];
963 * Get correcresponse and currentinfo depending on grading type.
965 * @param string $gradingtype The kind of grading.
966 * @return array Correctresponse and currentresponsescore in one array.
968 protected function get_response_depend_on_grading_type(string $gradingtype): array {
970 $correctresponse = [];
971 $currentresponse = [];
972 switch ($gradingtype) {
973 case self::GRADING_ALL_OR_NOTHING:
974 case self::GRADING_ABSOLUTE_POSITION:
975 case self::GRADING_RELATIVE_TO_CORRECT:
976 $correctresponse = $this->correctresponse;
977 $currentresponse = $this->currentresponse;
978 break;
980 case self::GRADING_RELATIVE_NEXT_EXCLUDE_LAST:
981 case self::GRADING_RELATIVE_NEXT_INCLUDE_LAST:
982 $lastitem = ($gradingtype == self::GRADING_RELATIVE_NEXT_INCLUDE_LAST);
983 $correctresponse = $this->get_next_answerids($this->correctresponse, $lastitem);
984 $currentresponse = $this->get_next_answerids($this->currentresponse, $lastitem);
985 break;
987 case self::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT:
988 case self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT:
989 $all = ($gradingtype == self::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT);
990 $correctresponse = $this->get_previous_and_next_answerids($this->correctresponse, $all);
991 $currentresponse = $this->get_previous_and_next_answerids($this->currentresponse, $all);
992 break;
994 case self::GRADING_LONGEST_ORDERED_SUBSET:
995 case self::GRADING_LONGEST_CONTIGUOUS_SUBSET:
996 $correctresponse = $this->correctresponse;
997 $currentresponse = $this->currentresponse;
998 $contiguous = ($gradingtype == self::GRADING_LONGEST_CONTIGUOUS_SUBSET);
999 $subset = $this->get_ordered_subset($contiguous);
1000 foreach ($currentresponse as $position => $answerid) {
1001 if (array_search($position, $subset) === false) {
1002 $currentresponse[$position] = 0;
1003 } else {
1004 $currentresponse[$position] = 1;
1007 break;
1010 return [$correctresponse, $currentresponse];
1014 * Returns score for one item depending on correctness and question settings.
1016 * @param question_definition $question question definition object
1017 * @param int $position The position of the current response.
1018 * @param int $answerid The answerid of the current response.
1019 * @return array (score, maxscore, fraction, percent, class)
1021 public function get_ordering_item_score(question_definition $question, int $position, int $answerid): array {
1023 if (!isset($this->itemscores[$position])) {
1025 [$correctresponse, $currentresponse] = $this->get_response_depend_on_grading_type($question->options->gradingtype);
1027 $percent = 0; // 100 * $fraction.
1028 [$fraction, $score, $maxscore] =
1029 $this->get_fraction_maxscore_score_of_item($position, $answerid, $correctresponse, $currentresponse);
1031 if ($maxscore === null) {
1032 // An unscored item is either an illegal item
1033 // or last item of RELATIVE_NEXT_EXCLUDE_LAST
1034 // or an item in an incorrect ALL_OR_NOTHING
1035 // or an item from an unrecognized grading type.
1036 $class = 'unscored';
1037 } else {
1038 if ($maxscore > 0) {
1039 $percent = round(100 * $fraction, 0);
1041 $class = match (true) {
1042 $fraction > 0.999999 => 'correct',
1043 $fraction < 0.000001 => 'incorrect',
1044 $fraction >= 0.66 => 'partial66',
1045 $fraction >= 0.33 => 'partial33',
1046 default => 'partial00',
1050 $score = [$score, $maxscore, $fraction, $percent, $class];
1051 $this->itemscores[$position] = $score;
1054 return $this->itemscores[$position];