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] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]

   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   * Multianswer question renderer classes.
  19   * Handle shortanswer, numerical and various multichoice subquestions
  20   *
  21   * @package    qtype
  22   * @subpackage multianswer
  23   * @copyright  2010 Pierre Pichet
  24   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  25   */
  26  
  27  
  28  require_once($CFG->dirroot . '/question/type/shortanswer/renderer.php');
  29  
  30  
  31  /**
  32   * Base class for generating the bits of output common to multianswer
  33   * (Cloze) questions.
  34   * This render the main question text and transfer to the subquestions
  35   * the task of display their input elements and status
  36   * feedback, grade, correct answer(s)
  37   *
  38   * @copyright 2010 Pierre Pichet
  39   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40   */
  41  class qtype_multianswer_renderer extends qtype_renderer {
  42  
  43      public function formulation_and_controls(question_attempt $qa,
  44              question_display_options $options) {
  45          $question = $qa->get_question();
  46  
  47          $output = '';
  48          $subquestions = array();
  49  
  50          $missingsubquestions = false;
  51          foreach ($question->textfragments as $i => $fragment) {
  52              if ($i > 0) {
  53                  $index = $question->places[$i];
  54                  $questionisvalid = !empty($question->subquestions[$index]) &&
  55                                   $question->subquestions[$index]->qtype->name() !== 'subquestion_replacement';
  56  
  57                  if (!$questionisvalid) {
  58                      $missingsubquestions = true;
  59                      $questionreplacement = qtype_multianswer::deleted_subquestion_replacement();
  60  
  61                      // It is possible that the subquestion index does not exist. When corrupted quizzes (see MDL-54724) are
  62                      // restored, the sequence column of mdl_quiz_multianswer can be empty, in this case
  63                      // qtype_multianswer::get_question_options cannot fill in deleted questions, so we need to do it here.
  64                      $question->subquestions[$index] = $question->subquestions[$index] ?? $questionreplacement;
  65                  }
  66  
  67                  $token = 'qtypemultianswer' . $i . 'marker';
  68                  $token = '<span class="nolink">' . $token . '</span>';
  69                  $output .= $token;
  70                  $subquestions[$token] = $this->subquestion($qa, $options, $index,
  71                          $question->subquestions[$index]);
  72              }
  73  
  74              $output .= $fragment;
  75          }
  76  
  77          if ($missingsubquestions) {
  78              $output = $this->notification(get_string('corruptedquestion', 'qtype_multianswer'), 'error') . $output;
  79          }
  80  
  81          $output = $question->format_text($output, $question->questiontextformat,
  82                  $qa, 'question', 'questiontext', $question->id);
  83          $output = str_replace(array_keys($subquestions), array_values($subquestions), $output);
  84  
  85          if ($qa->get_state() == question_state::$invalid) {
  86              $output .= html_writer::nonempty_tag('div',
  87                      $question->get_validation_error($qa->get_last_qt_data()),
  88                      array('class' => 'validationerror'));
  89          }
  90  
  91          return $output;
  92      }
  93  
  94      public function subquestion(question_attempt $qa,
  95              question_display_options $options, $index, question_automatically_gradable $subq) {
  96  
  97          $subtype = $subq->qtype->name();
  98          if ($subtype == 'numerical' || $subtype == 'shortanswer') {
  99              $subrenderer = 'textfield';
 100          } else if ($subtype == 'multichoice') {
 101              if ($subq instanceof qtype_multichoice_multi_question) {
 102                  if ($subq->layout == qtype_multichoice_base::LAYOUT_VERTICAL) {
 103                      $subrenderer = 'multiresponse_vertical';
 104                  } else {
 105                      $subrenderer = 'multiresponse_horizontal';
 106                  }
 107              } else {
 108                  if ($subq->layout == qtype_multichoice_base::LAYOUT_DROPDOWN) {
 109                      $subrenderer = 'multichoice_inline';
 110                  } else if ($subq->layout == qtype_multichoice_base::LAYOUT_HORIZONTAL) {
 111                      $subrenderer = 'multichoice_horizontal';
 112                  } else {
 113                      $subrenderer = 'multichoice_vertical';
 114                  }
 115              }
 116          } else if ($subtype == 'subquestion_replacement') {
 117              return html_writer::div(
 118                  get_string('missingsubquestion', 'qtype_multianswer'),
 119                  'notifyproblem'
 120              );
 121          } else {
 122              throw new coding_exception('Unexpected subquestion type.', $subq);
 123          }
 124          /** @var qtype_multianswer_subq_renderer_base $renderer */
 125          $renderer = $this->page->get_renderer('qtype_multianswer', $subrenderer);
 126          return $renderer->subquestion($qa, $options, $index, $subq);
 127      }
 128  
 129      public function correct_response(question_attempt $qa) {
 130          return '';
 131      }
 132  }
 133  
 134  
 135  /**
 136   * Subclass for generating the bits of output specific to shortanswer
 137   * subquestions.
 138   *
 139   * @copyright 2011 The Open University
 140   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 141   */
 142  abstract class qtype_multianswer_subq_renderer_base extends qtype_renderer {
 143  
 144      /** @var int[] Stores the counts of answer instances for questions. */
 145      protected static $answercount = [];
 146  
 147      /** @var question_display_options Question display options instance for any necessary information for rendering the question. */
 148      protected $displayoptions;
 149  
 150      abstract public function subquestion(question_attempt $qa,
 151              question_display_options $options, $index,
 152              question_graded_automatically $subq);
 153  
 154      /**
 155       * Render the feedback pop-up contents.
 156       *
 157       * @param question_graded_automatically $subq the subquestion.
 158       * @param float $fraction the mark the student got. null if this subq was not answered.
 159       * @param string $feedbacktext the feedback text, already processed with format_text etc.
 160       * @param string $rightanswer the right answer, already processed with format_text etc.
 161       * @param question_display_options $options the display options.
 162       * @return string the HTML for the feedback popup.
 163       */
 164      protected function feedback_popup(question_graded_automatically $subq,
 165              $fraction, $feedbacktext, $rightanswer, question_display_options $options) {
 166  
 167          $feedback = array();
 168          if ($options->correctness) {
 169              if (is_null($fraction)) {
 170                  $state = question_state::$gaveup;
 171              } else {
 172                  $state = question_state::graded_state_for_fraction($fraction);
 173              }
 174              $feedback[] = $state->default_string(true);
 175          }
 176  
 177          if ($options->feedback && $feedbacktext) {
 178              $feedback[] = $feedbacktext;
 179          }
 180  
 181          if ($options->rightanswer) {
 182              $feedback[] = get_string('correctansweris', 'qtype_shortanswer', $rightanswer);
 183          }
 184  
 185          $subfraction = '';
 186          if ($options->marks >= question_display_options::MARK_AND_MAX && $subq->maxmark > 0
 187                  && (!is_null($fraction) || $feedback)) {
 188              $a = new stdClass();
 189              $a->mark = format_float($fraction * $subq->maxmark, $options->markdp);
 190              $a->max = format_float($subq->maxmark, $options->markdp);
 191              $feedback[] = get_string('markoutofmax', 'question', $a);
 192          }
 193  
 194          if (!$feedback) {
 195              return '';
 196          }
 197  
 198          return html_writer::tag('span', implode('<br />', $feedback), [
 199              'class' => 'feedbackspan',
 200          ]);
 201      }
 202  
 203      /**
 204       * Render the feedback icon for a sub-question which is also the trigger for the feedback popover.
 205       *
 206       * @param string $icon The feedback icon
 207       * @param string $feedbackcontents The feedback contents to be shown on the popover.
 208       * @return string
 209       */
 210      protected function get_feedback_image(string $icon, string $feedbackcontents): string {
 211          global $PAGE;
 212          if ($icon === '') {
 213              return '';
 214          }
 215  
 216          $PAGE->requires->js_call_amd('qtype_multianswer/feedback', 'initPopovers');
 217  
 218          return html_writer::link('#', $icon, [
 219              'role' => 'button',
 220              'tabindex' => 0,
 221              'class' => 'feedbacktrigger btn btn-link p-0',
 222              'data-toggle' => 'popover',
 223              'data-container' => 'body',
 224              'data-content' => $feedbackcontents,
 225              'data-placement' => 'right',
 226              'data-trigger' => 'hover focus',
 227              'data-html' => 'true',
 228          ]);
 229      }
 230  
 231      /**
 232       * Generates a label for an answer field.
 233       *
 234       * If the question number is set ({@see qtype_renderer::$questionnumber}), the label will
 235       * include the question number in order to indicate which question the answer field belongs to.
 236       *
 237       * @param string $langkey The lang string key for the lang string that does not include the question number.
 238       * @param string $component The Frankenstyle component name.
 239       * @return string
 240       * @throws coding_exception
 241       */
 242      protected function get_answer_label(
 243          string $langkey = 'answerx',
 244          string $component = 'question'
 245      ): string {
 246          // There may be multiple answer fields for a question, so we need to increment the answer fields in order to distinguish
 247          // them from one another.
 248          $questionnumber = $this->displayoptions->questionidentifier ?? '';
 249          $questionnumberindex = $questionnumber !== '' ? $questionnumber : 0;
 250          if (isset(self::$answercount[$questionnumberindex][$langkey])) {
 251              self::$answercount[$questionnumberindex][$langkey]++;
 252          } else {
 253              self::$answercount[$questionnumberindex][$langkey] = 1;
 254          }
 255  
 256          $params = self::$answercount[$questionnumberindex][$langkey];
 257  
 258          return $this->displayoptions->add_question_identifier_to_label(get_string($langkey, $component, $params));
 259      }
 260  }
 261  
 262  
 263  /**
 264   * Subclass for generating the bits of output specific to shortanswer
 265   * subquestions.
 266   *
 267   * @copyright 2011 The Open University
 268   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 269   */
 270  class qtype_multianswer_textfield_renderer extends qtype_multianswer_subq_renderer_base {
 271  
 272      public function subquestion(question_attempt $qa, question_display_options $options,
 273              $index, question_graded_automatically $subq) {
 274  
 275          $this->displayoptions = $options;
 276  
 277          $fieldprefix = 'sub' . $index . '_';
 278          $fieldname = $fieldprefix . 'answer';
 279  
 280          $response = $qa->get_last_qt_var($fieldname);
 281          if ($subq->qtype->name() == 'shortanswer') {
 282              $matchinganswer = $subq->get_matching_answer(array('answer' => $response));
 283          } else if ($subq->qtype->name() == 'numerical') {
 284              list($value, $unit, $multiplier) = $subq->ap->apply_units($response, '');
 285              $matchinganswer = $subq->get_matching_answer($value, 1);
 286          } else {
 287              $matchinganswer = $subq->get_matching_answer($response);
 288          }
 289  
 290          if (!$matchinganswer) {
 291              if (is_null($response) || $response === '') {
 292                  $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
 293              } else {
 294                  $matchinganswer = new question_answer(0, '', 0.0, '', FORMAT_HTML);
 295              }
 296          }
 297  
 298          // Work out a good input field size.
 299          $size = max(1, core_text::strlen(trim($response ?? '')) + 1);
 300          foreach ($subq->answers as $ans) {
 301              $size = max($size, core_text::strlen(trim($ans->answer)));
 302          }
 303          $size = min(60, round($size + rand(0, (int)($size * 0.15))));
 304          // The rand bit is to make guessing harder.
 305  
 306          $inputattributes = array(
 307              'type' => 'text',
 308              'name' => $qa->get_qt_field_name($fieldname),
 309              'value' => $response,
 310              'id' => $qa->get_qt_field_name($fieldname),
 311              'size' => $size,
 312              'class' => 'form-control mb-1',
 313          );
 314          if ($options->readonly) {
 315              $inputattributes['readonly'] = 'readonly';
 316          }
 317  
 318          $feedbackimg = '';
 319          if ($options->correctness) {
 320              $inputattributes['class'] .= ' ' . $this->feedback_class($matchinganswer->fraction);
 321              $feedbackimg = $this->feedback_image($matchinganswer->fraction);
 322          }
 323  
 324          if ($subq->qtype->name() == 'shortanswer') {
 325              $correctanswer = $subq->get_matching_answer($subq->get_correct_response());
 326          } else {
 327              $correctanswer = $subq->get_correct_answer();
 328          }
 329  
 330          $feedbackpopup = $this->feedback_popup($subq, $matchinganswer->fraction,
 331                  $subq->format_text($matchinganswer->feedback, $matchinganswer->feedbackformat,
 332                          $qa, 'question', 'answerfeedback', $matchinganswer->id),
 333                  s($correctanswer->answer), $options);
 334  
 335          $output = html_writer::start_tag('span', array('class' => 'subquestion form-inline d-inline'));
 336  
 337          $output .= html_writer::tag('label', $this->get_answer_label(),
 338                  array('class' => 'subq accesshide', 'for' => $inputattributes['id']));
 339          $output .= html_writer::empty_tag('input', $inputattributes);
 340          $output .= $this->get_feedback_image($feedbackimg, $feedbackpopup);
 341          $output .= html_writer::end_tag('span');
 342  
 343          return $output;
 344      }
 345  }
 346  
 347  
 348  /**
 349   * Render an embedded multiple-choice question that is displayed as a select menu.
 350   *
 351   * @copyright  2011 The Open University
 352   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 353   */
 354  class qtype_multianswer_multichoice_inline_renderer
 355          extends qtype_multianswer_subq_renderer_base {
 356  
 357      public function subquestion(question_attempt $qa, question_display_options $options,
 358              $index, question_graded_automatically $subq) {
 359  
 360          $this->displayoptions = $options;
 361  
 362          $fieldprefix = 'sub' . $index . '_';
 363          $fieldname = $fieldprefix . 'answer';
 364  
 365          $response = $qa->get_last_qt_var($fieldname);
 366          $choices = array();
 367          $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
 368          $rightanswer = null;
 369          foreach ($subq->get_order($qa) as $value => $ansid) {
 370              $ans = $subq->answers[$ansid];
 371              $choices[$value] = $subq->format_text($ans->answer, $ans->answerformat,
 372                      $qa, 'question', 'answer', $ansid);
 373              if ($subq->is_choice_selected($response, $value)) {
 374                  $matchinganswer = $ans;
 375              }
 376          }
 377  
 378          $inputattributes = array(
 379              'id' => $qa->get_qt_field_name($fieldname),
 380          );
 381          if ($options->readonly) {
 382              $inputattributes['disabled'] = 'disabled';
 383          }
 384  
 385          $feedbackimg = '';
 386          if ($options->correctness) {
 387              $inputattributes['class'] = $this->feedback_class($matchinganswer->fraction);
 388              $feedbackimg = $this->feedback_image($matchinganswer->fraction);
 389          }
 390          $select = html_writer::select($choices, $qa->get_qt_field_name($fieldname),
 391                  $response, array('' => '&nbsp;'), $inputattributes);
 392  
 393          $order = $subq->get_order($qa);
 394          $correctresponses = $subq->get_correct_response();
 395          $rightanswer = $subq->answers[$order[reset($correctresponses)]];
 396          if (!$matchinganswer) {
 397              $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
 398          }
 399          $feedbackpopup = $this->feedback_popup($subq, $matchinganswer->fraction,
 400                  $subq->format_text($matchinganswer->feedback, $matchinganswer->feedbackformat,
 401                          $qa, 'question', 'answerfeedback', $matchinganswer->id),
 402                  $subq->format_text($rightanswer->answer, $rightanswer->answerformat,
 403                          $qa, 'question', 'answer', $rightanswer->id), $options);
 404  
 405          $output = html_writer::start_tag('span', array('class' => 'subquestion'));
 406          $output .= html_writer::tag('label', $this->get_answer_label(),
 407                  array('class' => 'subq accesshide', 'for' => $inputattributes['id']));
 408          $output .= $select;
 409          $output .= $this->get_feedback_image($feedbackimg, $feedbackpopup);
 410          $output .= html_writer::end_tag('span');
 411  
 412          return $output;
 413      }
 414  }
 415  
 416  
 417  /**
 418   * Render an embedded multiple-choice question vertically, like for a normal
 419   * multiple-choice question.
 420   *
 421   * @copyright  2010 Pierre Pichet
 422   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 423   */
 424  class qtype_multianswer_multichoice_vertical_renderer extends qtype_multianswer_subq_renderer_base {
 425  
 426      public function subquestion(question_attempt $qa, question_display_options $options,
 427              $index, question_graded_automatically $subq) {
 428  
 429          $this->displayoptions = $options;
 430  
 431          $fieldprefix = 'sub' . $index . '_';
 432          $fieldname = $fieldprefix . 'answer';
 433          $response = $qa->get_last_qt_var($fieldname);
 434  
 435          $inputattributes = array(
 436              'type' => 'radio',
 437              'name' => $qa->get_qt_field_name($fieldname),
 438              'class' => 'form-check-input',
 439          );
 440          if ($options->readonly) {
 441              $inputattributes['disabled'] = 'disabled';
 442          }
 443  
 444          $result = $this->all_choices_wrapper_start();
 445          $fraction = null;
 446          foreach ($subq->get_order($qa) as $value => $ansid) {
 447              $ans = $subq->answers[$ansid];
 448  
 449              $inputattributes['value'] = $value;
 450              $inputattributes['id'] = $inputattributes['name'] . $value;
 451  
 452              $isselected = $subq->is_choice_selected($response, $value);
 453              if ($isselected) {
 454                  $inputattributes['checked'] = 'checked';
 455                  $fraction = $ans->fraction;
 456              } else {
 457                  unset($inputattributes['checked']);
 458              }
 459  
 460              $class = 'form-check text-wrap text-break';
 461              if ($options->correctness && $isselected) {
 462                  $feedbackimg = $this->feedback_image($ans->fraction);
 463                  $class .= ' ' . $this->feedback_class($ans->fraction);
 464              } else {
 465                  $feedbackimg = '';
 466              }
 467  
 468              $result .= $this->choice_wrapper_start($class);
 469              $result .= html_writer::empty_tag('input', $inputattributes);
 470              $result .= html_writer::tag('label', $subq->format_text($ans->answer,
 471                      $ans->answerformat, $qa, 'question', 'answer', $ansid),
 472                      array('for' => $inputattributes['id'], 'class' => 'form-check-label text-body'));
 473              $result .= $feedbackimg;
 474  
 475              if ($options->feedback && $isselected && trim($ans->feedback)) {
 476                  $result .= html_writer::tag('div',
 477                          $subq->format_text($ans->feedback, $ans->feedbackformat,
 478                                  $qa, 'question', 'answerfeedback', $ansid),
 479                          array('class' => 'specificfeedback'));
 480              }
 481  
 482              $result .= $this->choice_wrapper_end();
 483          }
 484  
 485          $result .= $this->all_choices_wrapper_end();
 486  
 487          $feedback = array();
 488          if ($options->feedback && $options->marks >= question_display_options::MARK_AND_MAX &&
 489                  $subq->maxmark > 0) {
 490              $a = new stdClass();
 491              $a->mark = format_float($fraction * $subq->maxmark, $options->markdp);
 492              $a->max = format_float($subq->maxmark, $options->markdp);
 493  
 494              $feedback[] = html_writer::tag('div', get_string('markoutofmax', 'question', $a));
 495          }
 496  
 497          if ($options->rightanswer) {
 498              foreach ($subq->answers as $ans) {
 499                  if (question_state::graded_state_for_fraction($ans->fraction) ==
 500                          question_state::$gradedright) {
 501                      $feedback[] = get_string('correctansweris', 'qtype_multichoice',
 502                              $subq->format_text($ans->answer, $ans->answerformat,
 503                                      $qa, 'question', 'answer', $ansid));
 504                      break;
 505                  }
 506              }
 507          }
 508  
 509          $result .= html_writer::nonempty_tag('div', implode('<br />', $feedback), array('class' => 'outcome'));
 510  
 511          return $result;
 512      }
 513  
 514      /**
 515       * @param string $class class attribute value.
 516       * @return string HTML to go before each choice.
 517       */
 518      protected function choice_wrapper_start($class) {
 519          return html_writer::start_tag('div', array('class' => $class));
 520      }
 521  
 522      /**
 523       * @return string HTML to go after each choice.
 524       */
 525      protected function choice_wrapper_end() {
 526          return html_writer::end_tag('div');
 527      }
 528  
 529      /**
 530       * @return string HTML to go before all the choices.
 531       */
 532      protected function all_choices_wrapper_start() {
 533          $wrapperstart = html_writer::start_tag('fieldset', array('class' => 'answer'));
 534          $legendtext = $this->get_answer_label('multichoicex', 'qtype_multianswer');
 535          $wrapperstart .= html_writer::tag('legend', $legendtext, ['class' => 'sr-only']);
 536          return $wrapperstart;
 537      }
 538  
 539      /**
 540       * @return string HTML to go after all the choices.
 541       */
 542      protected function all_choices_wrapper_end() {
 543          return html_writer::end_tag('fieldset');
 544      }
 545  }
 546  
 547  
 548  /**
 549   * Render an embedded multiple-choice question vertically, like for a normal
 550   * multiple-choice question.
 551   *
 552   * @copyright  2010 Pierre Pichet
 553   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 554   */
 555  class qtype_multianswer_multichoice_horizontal_renderer
 556          extends qtype_multianswer_multichoice_vertical_renderer {
 557  
 558      protected function choice_wrapper_start($class) {
 559          return html_writer::start_tag('div', array('class' => $class . ' form-check-inline'));
 560      }
 561  
 562      protected function choice_wrapper_end() {
 563          return html_writer::end_tag('div');
 564      }
 565  
 566      protected function all_choices_wrapper_start() {
 567          $wrapperstart = html_writer::start_tag('fieldset', ['class' => 'answer']);
 568          $captiontext = $this->get_answer_label('multichoicex', 'qtype_multianswer');
 569          $wrapperstart .= html_writer::tag('legend', $captiontext, ['class' => 'sr-only']);
 570          return $wrapperstart;
 571      }
 572  
 573      protected function all_choices_wrapper_end() {
 574          return html_writer::end_tag('fieldset');
 575      }
 576  }
 577  
 578  /**
 579   * Class qtype_multianswer_multiresponse_renderer
 580   *
 581   * @copyright  2016 Davo Smith, Synergy Learning
 582   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 583   */
 584  class qtype_multianswer_multiresponse_vertical_renderer extends qtype_multianswer_subq_renderer_base {
 585  
 586      /**
 587       * Output the content of the subquestion.
 588       *
 589       * @param question_attempt $qa
 590       * @param question_display_options $options
 591       * @param int $index
 592       * @param question_graded_automatically $subq
 593       * @return string
 594       */
 595      public function subquestion(question_attempt $qa, question_display_options $options,
 596                                  $index, question_graded_automatically $subq) {
 597  
 598          if (!$subq instanceof qtype_multichoice_multi_question) {
 599              throw new coding_exception('Expecting subquestion of type qtype_multichoice_multi_question');
 600          }
 601  
 602          $fieldprefix = 'sub' . $index . '_';
 603          $fieldname = $fieldprefix . 'choice';
 604  
 605          // Extract the responses that related to this question + strip off the prefix.
 606          $fieldprefixlen = strlen($fieldprefix);
 607          $response = [];
 608          foreach ($qa->get_last_qt_data() as $name => $val) {
 609              if (substr($name, 0, $fieldprefixlen) == $fieldprefix) {
 610                  $name = substr($name, $fieldprefixlen);
 611                  $response[$name] = $val;
 612              }
 613          }
 614  
 615          $basename = $qa->get_qt_field_name($fieldname);
 616          $inputattributes = array(
 617              'type' => 'checkbox',
 618              'value' => 1,
 619          );
 620          if ($options->readonly) {
 621              $inputattributes['disabled'] = 'disabled';
 622          }
 623  
 624          $result = $this->all_choices_wrapper_start();
 625  
 626          // Calculate the total score (as we need to know if choices should be marked as 'correct' or 'partial').
 627          $fraction = 0;
 628          foreach ($subq->get_order($qa) as $value => $ansid) {
 629              $ans = $subq->answers[$ansid];
 630              if ($subq->is_choice_selected($response, $value)) {
 631                  $fraction += $ans->fraction;
 632              }
 633          }
 634          // Display 'correct' answers as correct, if we are at 100%, otherwise mark them as 'partial'.
 635          $answerfraction = ($fraction > 0.999) ? 1.0 : 0.5;
 636  
 637          foreach ($subq->get_order($qa) as $value => $ansid) {
 638              $ans = $subq->answers[$ansid];
 639  
 640              $name = $basename.$value;
 641              $inputattributes['name'] = $name;
 642              $inputattributes['id'] = $name;
 643  
 644              $isselected = $subq->is_choice_selected($response, $value);
 645              if ($isselected) {
 646                  $inputattributes['checked'] = 'checked';
 647              } else {
 648                  unset($inputattributes['checked']);
 649              }
 650  
 651              $class = 'r' . ($value % 2);
 652              if ($options->correctness && $isselected) {
 653                  $thisfrac = ($ans->fraction > 0) ? $answerfraction : 0;
 654                  $feedbackimg = $this->feedback_image($thisfrac);
 655                  $class .= ' ' . $this->feedback_class($thisfrac);
 656              } else {
 657                  $feedbackimg = '';
 658              }
 659  
 660              $result .= $this->choice_wrapper_start($class);
 661              $result .= html_writer::empty_tag('input', $inputattributes);
 662              $result .= html_writer::tag('label', $subq->format_text($ans->answer,
 663                                                                      $ans->answerformat, $qa, 'question', 'answer', $ansid),
 664                                          array('for' => $inputattributes['id']));
 665              $result .= $feedbackimg;
 666  
 667              if ($options->feedback && $isselected && trim($ans->feedback)) {
 668                  $result .= html_writer::tag('div',
 669                                              $subq->format_text($ans->feedback, $ans->feedbackformat,
 670                                                                 $qa, 'question', 'answerfeedback', $ansid),
 671                                              array('class' => 'specificfeedback'));
 672              }
 673  
 674              $result .= $this->choice_wrapper_end();
 675          }
 676  
 677          $result .= $this->all_choices_wrapper_end();
 678  
 679          $feedback = array();
 680          if ($options->feedback && $options->marks >= question_display_options::MARK_AND_MAX &&
 681              $subq->maxmark > 0) {
 682              $a = new stdClass();
 683              $a->mark = format_float($fraction * $subq->maxmark, $options->markdp);
 684              $a->max = format_float($subq->maxmark, $options->markdp);
 685  
 686              $feedback[] = html_writer::tag('div', get_string('markoutofmax', 'question', $a));
 687          }
 688  
 689          if ($options->rightanswer) {
 690              $correct = [];
 691              foreach ($subq->answers as $ans) {
 692                  if (question_state::graded_state_for_fraction($ans->fraction) != question_state::$gradedwrong) {
 693                      $correct[] = $subq->format_text($ans->answer, $ans->answerformat, $qa, 'question', 'answer', $ans->id);
 694                  }
 695              }
 696              $correct = '<ul><li>'.implode('</li><li>', $correct).'</li></ul>';
 697              $feedback[] = get_string('correctansweris', 'qtype_multichoice', $correct);
 698          }
 699  
 700          $result .= html_writer::nonempty_tag('div', implode('<br />', $feedback), array('class' => 'outcome'));
 701  
 702          return $result;
 703      }
 704  
 705      /**
 706       * @param string $class class attribute value.
 707       * @return string HTML to go before each choice.
 708       */
 709      protected function choice_wrapper_start($class) {
 710          return html_writer::start_tag('div', array('class' => $class));
 711      }
 712  
 713      /**
 714       * @return string HTML to go after each choice.
 715       */
 716      protected function choice_wrapper_end() {
 717          return html_writer::end_tag('div');
 718      }
 719  
 720      /**
 721       * @return string HTML to go before all the choices.
 722       */
 723      protected function all_choices_wrapper_start() {
 724          return html_writer::start_tag('div', array('class' => 'answer'));
 725      }
 726  
 727      /**
 728       * @return string HTML to go after all the choices.
 729       */
 730      protected function all_choices_wrapper_end() {
 731          return html_writer::end_tag('div');
 732      }
 733  }
 734  
 735  /**
 736   * Render an embedded multiple-response question horizontally.
 737   *
 738   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 739   */
 740  class qtype_multianswer_multiresponse_horizontal_renderer
 741      extends qtype_multianswer_multiresponse_vertical_renderer {
 742  
 743      protected function choice_wrapper_start($class) {
 744          return html_writer::start_tag('td', array('class' => $class));
 745      }
 746  
 747      protected function choice_wrapper_end() {
 748          return html_writer::end_tag('td');
 749      }
 750  
 751      protected function all_choices_wrapper_start() {
 752          return html_writer::start_tag('table', array('class' => 'answer')) .
 753          html_writer::start_tag('tbody') . html_writer::start_tag('tr');
 754      }
 755  
 756      protected function all_choices_wrapper_end() {
 757          return html_writer::end_tag('tr') . html_writer::end_tag('tbody') .
 758          html_writer::end_tag('table');
 759      }
 760  }