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 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   * Renderers for outputting parts of the question engine.
  19   *
  20   * @package    moodlecore
  21   * @subpackage questionengine
  22   * @copyright  2009 The Open University
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  
  30  /**
  31   * This renderer controls the overall output of questions. It works with a
  32   * {@link qbehaviour_renderer} and a {@link qtype_renderer} to output the
  33   * type-specific bits. The main entry point is the {@link question()} method.
  34   *
  35   * @copyright  2009 The Open University
  36   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  class core_question_renderer extends plugin_renderer_base {
  39      public function get_page() {
  40          return $this->page;
  41      }
  42  
  43      /**
  44       * Render an icon, optionally with the word 'Preview' beside it, to preview
  45       * a given question.
  46       * @param int $questionid the id of the question to be previewed.
  47       * @param context $context the context in which the preview is happening.
  48       *      Must be a course or category context.
  49       * @param bool $showlabel if true, show the word 'Preview' after the icon.
  50       *      If false, just show the icon.
  51       * @deprecated since Moodle 4.0
  52       * @see qbank_previewquestion\output\renderer
  53       * @todo Final deprecation on Moodle 4.4 MDL-72438
  54       */
  55      public function question_preview_link($questionid, context $context, $showlabel) {
  56           debugging('Function question_preview_link() has been deprecated and moved to qbank_previewquestion plugin,
  57           Please use qbank_previewquestion renderer.', DEBUG_DEVELOPER);
  58  
  59          return $this->page->get_renderer('qbank_previewquestion')->question_preview_link(
  60                  $questionid, $context, $showlabel
  61          );
  62      }
  63  
  64      /**
  65       * Generate the display of a question in a particular state, and with certain
  66       * display options. Normally you do not call this method directly. Intsead
  67       * you call {@link question_usage_by_activity::render_question()} which will
  68       * call this method with appropriate arguments.
  69       *
  70       * @param question_attempt $qa the question attempt to display.
  71       * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
  72       *      specific parts.
  73       * @param qtype_renderer $qtoutput the renderer to output the question type
  74       *      specific parts.
  75       * @param question_display_options $options controls what should and should not be displayed.
  76       * @param string|null $number The question number to display. 'i' is a special
  77       *      value that gets displayed as Information. Null means no number is displayed.
  78       * @return string HTML representation of the question.
  79       */
  80      public function question(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
  81              qtype_renderer $qtoutput, question_display_options $options, $number) {
  82  
  83          // If not already set, record the questionidentifier.
  84          $options = clone($options);
  85          if (!$options->has_question_identifier()) {
  86              $options->questionidentifier = $this->question_number_text($number);
  87          }
  88  
  89          $output = '';
  90          $output .= html_writer::start_tag('div', array(
  91              'id' => $qa->get_outer_question_div_unique_id(),
  92              'class' => implode(' ', array(
  93                  'que',
  94                  $qa->get_question(false)->get_type_name(),
  95                  $qa->get_behaviour_name(),
  96                  $qa->get_state_class($options->correctness && $qa->has_marks()),
  97              ))
  98          ));
  99  
 100          $output .= html_writer::tag('div',
 101                  $this->info($qa, $behaviouroutput, $qtoutput, $options, $number),
 102                  array('class' => 'info'));
 103  
 104          $output .= html_writer::start_tag('div', array('class' => 'content'));
 105  
 106          $output .= html_writer::tag('div',
 107                  $this->add_part_heading($qtoutput->formulation_heading(),
 108                      $this->formulation($qa, $behaviouroutput, $qtoutput, $options)),
 109                  array('class' => 'formulation clearfix'));
 110          $output .= html_writer::nonempty_tag('div',
 111                  $this->add_part_heading(get_string('feedback', 'question'),
 112                      $this->outcome($qa, $behaviouroutput, $qtoutput, $options)),
 113                  array('class' => 'outcome clearfix'));
 114          $output .= html_writer::nonempty_tag('div',
 115                  $this->add_part_heading(get_string('comments', 'question'),
 116                      $this->manual_comment($qa, $behaviouroutput, $qtoutput, $options)),
 117                  array('class' => 'comment clearfix'));
 118          $output .= html_writer::nonempty_tag('div',
 119                  $this->response_history($qa, $behaviouroutput, $qtoutput, $options),
 120                  array('class' => 'history clearfix border p-2'));
 121  
 122          $output .= html_writer::end_tag('div');
 123          $output .= html_writer::end_tag('div');
 124          return $output;
 125      }
 126  
 127      /**
 128       * Generate the information bit of the question display that contains the
 129       * metadata like the question number, current state, and mark.
 130       * @param question_attempt $qa the question attempt to display.
 131       * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
 132       *      specific parts.
 133       * @param qtype_renderer $qtoutput the renderer to output the question type
 134       *      specific parts.
 135       * @param question_display_options $options controls what should and should not be displayed.
 136       * @param string|null $number The question number to display. 'i' is a special
 137       *      value that gets displayed as Information. Null means no number is displayed.
 138       * @return HTML fragment.
 139       */
 140      protected function info(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
 141              qtype_renderer $qtoutput, question_display_options $options, $number) {
 142          $output = '';
 143          $output .= $this->number($number);
 144          $output .= $this->status($qa, $behaviouroutput, $options);
 145          $output .= $this->mark_summary($qa, $behaviouroutput, $options);
 146          $output .= $this->question_flag($qa, $options->flags);
 147          $output .= $this->edit_question_link($qa, $options);
 148          return $output;
 149      }
 150  
 151      /**
 152       * Generate the display of the question number.
 153       * @param string|null $number The question number to display. 'i' is a special
 154       *      value that gets displayed as Information. Null means no number is displayed.
 155       * @return HTML fragment.
 156       */
 157      protected function number($number) {
 158          if (trim($number ?? '') === '') {
 159              return '';
 160          }
 161          if (trim($number) === 'i') {
 162              $numbertext = get_string('information', 'question');
 163          } else {
 164              $numbertext = get_string('questionx', 'question',
 165                      html_writer::tag('span', s($number), array('class' => 'qno')));
 166          }
 167          return html_writer::tag('h3', $numbertext, array('class' => 'no'));
 168      }
 169  
 170      /**
 171       * Get the question number as a string.
 172       *
 173       * @param string|null $number e.g. '123' or 'i'. null or '' means do not display anything number-related.
 174       * @return string e.g. 'Question 123' or 'Information' or ''.
 175       */
 176      protected function question_number_text(?string $number): string {
 177          $number = $number ?? '';
 178          // Trim the question number of whitespace, including &nbsp;.
 179          $trimmed = trim(html_entity_decode($number), " \n\r\t\v\x00\xC2\xA0");
 180          if ($trimmed === '') {
 181              return '';
 182          }
 183          if (trim($number) === 'i') {
 184              return get_string('information', 'question');
 185          } else {
 186              return get_string('questionx', 'question', s($number));
 187          }
 188      }
 189  
 190      /**
 191       * Add an invisible heading like 'question text', 'feebdack' at the top of
 192       * a section's contents, but only if the section has some content.
 193       * @param string $heading the heading to add.
 194       * @param string $content the content of the section.
 195       * @return string HTML fragment with the heading added.
 196       */
 197      protected function add_part_heading($heading, $content) {
 198          if ($content) {
 199              $content = html_writer::tag('h4', $heading, array('class' => 'accesshide')) . $content;
 200          }
 201          return $content;
 202      }
 203  
 204      /**
 205       * Generate the display of the status line that gives the current state of
 206       * the question.
 207       * @param question_attempt $qa the question attempt to display.
 208       * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
 209       *      specific parts.
 210       * @param question_display_options $options controls what should and should not be displayed.
 211       * @return HTML fragment.
 212       */
 213      protected function status(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
 214              question_display_options $options) {
 215          return html_writer::tag('div', $qa->get_state_string($options->correctness),
 216                  array('class' => 'state'));
 217      }
 218  
 219      /**
 220       * Generate the display of the marks for this question.
 221       * @param question_attempt $qa the question attempt to display.
 222       * @param qbehaviour_renderer $behaviouroutput the behaviour renderer, which can generate a custom display.
 223       * @param question_display_options $options controls what should and should not be displayed.
 224       * @return HTML fragment.
 225       */
 226      protected function mark_summary(question_attempt $qa, qbehaviour_renderer $behaviouroutput, question_display_options $options) {
 227          return html_writer::nonempty_tag('div',
 228                  $behaviouroutput->mark_summary($qa, $this, $options),
 229                  array('class' => 'grade'));
 230      }
 231  
 232      /**
 233       * Generate the display of the marks for this question.
 234       * @param question_attempt $qa the question attempt to display.
 235       * @param question_display_options $options controls what should and should not be displayed.
 236       * @return HTML fragment.
 237       */
 238      public function standard_mark_summary(question_attempt $qa, qbehaviour_renderer $behaviouroutput, question_display_options $options) {
 239          if (!$options->marks) {
 240              return '';
 241  
 242          } else if ($qa->get_max_mark() == 0) {
 243              return get_string('notgraded', 'question');
 244  
 245          } else if ($options->marks == question_display_options::MAX_ONLY ||
 246                  is_null($qa->get_fraction())) {
 247              return $behaviouroutput->marked_out_of_max($qa, $this, $options);
 248  
 249          } else {
 250              return $behaviouroutput->mark_out_of_max($qa, $this, $options);
 251          }
 252      }
 253  
 254      /**
 255       * Generate the display of the available marks for this question.
 256       * @param question_attempt $qa the question attempt to display.
 257       * @param question_display_options $options controls what should and should not be displayed.
 258       * @return HTML fragment.
 259       */
 260      public function standard_marked_out_of_max(question_attempt $qa, question_display_options $options) {
 261          return get_string('markedoutofmax', 'question', $qa->format_max_mark($options->markdp));
 262      }
 263  
 264      /**
 265       * Generate the display of the marks for this question out of the available marks.
 266       * @param question_attempt $qa the question attempt to display.
 267       * @param question_display_options $options controls what should and should not be displayed.
 268       * @return HTML fragment.
 269       */
 270      public function standard_mark_out_of_max(question_attempt $qa, question_display_options $options) {
 271          $a = new stdClass();
 272          $a->mark = $qa->format_mark($options->markdp);
 273          $a->max = $qa->format_max_mark($options->markdp);
 274          return get_string('markoutofmax', 'question', $a);
 275      }
 276  
 277      /**
 278       * Render the question flag, assuming $flagsoption allows it.
 279       *
 280       * @param question_attempt $qa the question attempt to display.
 281       * @param int $flagsoption the option that says whether flags should be displayed.
 282       */
 283      protected function question_flag(question_attempt $qa, $flagsoption) {
 284          $divattributes = array('class' => 'questionflag');
 285  
 286          switch ($flagsoption) {
 287              case question_display_options::VISIBLE:
 288                  $flagcontent = $this->get_flag_html($qa->is_flagged());
 289                  break;
 290  
 291              case question_display_options::EDITABLE:
 292                  $id = $qa->get_flag_field_name();
 293                  // The checkbox id must be different from any element name, because
 294                  // of a stupid IE bug:
 295                  // http://www.456bereastreet.com/archive/200802/beware_of_id_and_name_attribute_mixups_when_using_getelementbyid_in_internet_explorer/
 296                  $checkboxattributes = array(
 297                      'type' => 'checkbox',
 298                      'id' => $id . 'checkbox',
 299                      'name' => $id,
 300                      'value' => 1,
 301                  );
 302                  if ($qa->is_flagged()) {
 303                      $checkboxattributes['checked'] = 'checked';
 304                  }
 305                  $postdata = question_flags::get_postdata($qa);
 306  
 307                  $flagcontent = html_writer::empty_tag('input',
 308                                  array('type' => 'hidden', 'name' => $id, 'value' => 0)) .
 309                          html_writer::empty_tag('input',
 310                                  array('type' => 'hidden', 'value' => $postdata, 'class' => 'questionflagpostdata')) .
 311                          html_writer::empty_tag('input', $checkboxattributes) .
 312                          html_writer::tag('label', $this->get_flag_html($qa->is_flagged(), $id . 'img'),
 313                                  array('id' => $id . 'label', 'for' => $id . 'checkbox')) . "\n";
 314  
 315                  $divattributes = array(
 316                      'class' => 'questionflag editable',
 317                  );
 318  
 319                  break;
 320  
 321              default:
 322                  $flagcontent = '';
 323          }
 324  
 325          return html_writer::nonempty_tag('div', $flagcontent, $divattributes);
 326      }
 327  
 328      /**
 329       * Work out the actual img tag needed for the flag
 330       *
 331       * @param bool $flagged whether the question is currently flagged.
 332       * @param string $id an id to be added as an attribute to the img (optional).
 333       * @return string the img tag.
 334       */
 335      protected function get_flag_html($flagged, $id = '') {
 336          if ($flagged) {
 337              $icon = 'i/flagged';
 338              $label = get_string('clickunflag', 'question');
 339          } else {
 340              $icon = 'i/unflagged';
 341              $label = get_string('clickflag', 'question');
 342          }
 343          $attributes = [
 344              'src' => $this->image_url($icon),
 345              'alt' => '',
 346              'class' => 'questionflagimage',
 347          ];
 348          if ($id) {
 349              $attributes['id'] = $id;
 350          }
 351          $img = html_writer::empty_tag('img', $attributes);
 352          $img .= html_writer::span($label);
 353  
 354          return $img;
 355      }
 356  
 357      /**
 358       * Generate the display of the edit question link.
 359       *
 360       * @param question_attempt $qa The question attempt to display.
 361       * @param question_display_options $options controls what should and should not be displayed.
 362       * @return string
 363       */
 364      protected function edit_question_link(question_attempt $qa, question_display_options $options) {
 365          if (empty($options->editquestionparams)) {
 366              return '';
 367          }
 368  
 369          $params = $options->editquestionparams;
 370          if ($params['returnurl'] instanceof moodle_url) {
 371              $params['returnurl'] = $params['returnurl']->out_as_local_url(false);
 372          }
 373          $params['id'] = $qa->get_question_id();
 374          $editurl = new moodle_url('/question/bank/editquestion/question.php', $params);
 375  
 376          return html_writer::tag('div', html_writer::link(
 377                  $editurl, $this->pix_icon('t/edit', get_string('edit'), '', array('class' => 'iconsmall')) .
 378                  get_string('editquestion', 'question')),
 379                  array('class' => 'editquestion'));
 380      }
 381  
 382      /**
 383       * Generate the display of the formulation part of the question. This is the
 384       * area that contains the quetsion text, and the controls for students to
 385       * input their answers. Some question types also embed feedback, for
 386       * example ticks and crosses, in this area.
 387       *
 388       * @param question_attempt $qa the question attempt to display.
 389       * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
 390       *      specific parts.
 391       * @param qtype_renderer $qtoutput the renderer to output the question type
 392       *      specific parts.
 393       * @param question_display_options $options controls what should and should not be displayed.
 394       * @return HTML fragment.
 395       */
 396      protected function formulation(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
 397              qtype_renderer $qtoutput, question_display_options $options) {
 398          $output = '';
 399          $output .= html_writer::empty_tag('input', array(
 400                  'type' => 'hidden',
 401                  'name' => $qa->get_control_field_name('sequencecheck'),
 402                  'value' => $qa->get_sequence_check_count()));
 403          $output .= $qtoutput->formulation_and_controls($qa, $options);
 404          if ($options->clearwrong) {
 405              $output .= $qtoutput->clear_wrong($qa);
 406          }
 407          $output .= html_writer::nonempty_tag('div',
 408                  $behaviouroutput->controls($qa, $options), array('class' => 'im-controls'));
 409          return $output;
 410      }
 411  
 412      /**
 413       * Generate the display of the outcome part of the question. This is the
 414       * area that contains the various forms of feedback.
 415       *
 416       * @param question_attempt $qa the question attempt to display.
 417       * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
 418       *      specific parts.
 419       * @param qtype_renderer $qtoutput the renderer to output the question type
 420       *      specific parts.
 421       * @param question_display_options $options controls what should and should not be displayed.
 422       * @return HTML fragment.
 423       */
 424      protected function outcome(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
 425              qtype_renderer $qtoutput, question_display_options $options) {
 426          $output = '';
 427          $output .= html_writer::nonempty_tag('div',
 428                  $qtoutput->feedback($qa, $options), array('class' => 'feedback'));
 429          $output .= html_writer::nonempty_tag('div',
 430                  $behaviouroutput->feedback($qa, $options), array('class' => 'im-feedback'));
 431          $output .= html_writer::nonempty_tag('div',
 432                  $options->extrainfocontent, array('class' => 'extra-feedback'));
 433          return $output;
 434      }
 435  
 436      protected function manual_comment(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
 437              qtype_renderer $qtoutput, question_display_options $options) {
 438          return $qtoutput->manual_comment($qa, $options) .
 439                  $behaviouroutput->manual_comment($qa, $options);
 440      }
 441  
 442      /**
 443       * Generate the display of the response history part of the question. This
 444       * is the table showing all the steps the question has been through.
 445       *
 446       * @param question_attempt $qa the question attempt to display.
 447       * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
 448       *      specific parts.
 449       * @param qtype_renderer $qtoutput the renderer to output the question type
 450       *      specific parts.
 451       * @param question_display_options $options controls what should and should not be displayed.
 452       * @return HTML fragment.
 453       */
 454      protected function response_history(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
 455              qtype_renderer $qtoutput, question_display_options $options) {
 456  
 457          if (!$options->history) {
 458              return '';
 459          }
 460  
 461          $table = new html_table();
 462          $table->head  = array (
 463              get_string('step', 'question'),
 464              get_string('time'),
 465              get_string('action', 'question'),
 466              get_string('state', 'question'),
 467          );
 468          if ($options->marks >= question_display_options::MARK_AND_MAX) {
 469              $table->head[] = get_string('marks', 'question');
 470          }
 471  
 472          foreach ($qa->get_full_step_iterator() as $i => $step) {
 473              $stepno = $i + 1;
 474  
 475              $rowclass = '';
 476              if ($stepno == $qa->get_num_steps()) {
 477                  $rowclass = 'current';
 478              } else if (!empty($options->questionreviewlink)) {
 479                  $url = new moodle_url($options->questionreviewlink,
 480                          array('slot' => $qa->get_slot(), 'step' => $i));
 481                  $stepno = $this->output->action_link($url, $stepno,
 482                          new popup_action('click', $url, 'reviewquestion',
 483                                  array('width' => 450, 'height' => 650)),
 484                          array('title' => get_string('reviewresponse', 'question')));
 485              }
 486  
 487              $restrictedqa = new question_attempt_with_restricted_history($qa, $i, null);
 488  
 489              $row = [$stepno,
 490                      userdate($step->get_timecreated(), get_string('strftimedatetimeshortaccurate', 'core_langconfig')),
 491                      s($qa->summarise_action($step)) . $this->action_author($step, $options),
 492                      $restrictedqa->get_state_string($options->correctness)];
 493  
 494              if ($options->marks >= question_display_options::MARK_AND_MAX) {
 495                  $row[] = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
 496              }
 497  
 498              $table->rowclasses[] = $rowclass;
 499              $table->data[] = $row;
 500          }
 501  
 502          return html_writer::tag('h4', get_string('responsehistory', 'question'),
 503                          array('class' => 'responsehistoryheader')) .
 504                  $options->extrahistorycontent .
 505                  html_writer::tag('div', html_writer::table($table, true),
 506                          array('class' => 'responsehistoryheader'));
 507      }
 508  
 509      /**
 510       * Action author's profile link.
 511       *
 512       * @param question_attempt_step $step The step.
 513       * @param question_display_options $options The display options.
 514       * @return string The link to user's profile.
 515       */
 516      protected function action_author(question_attempt_step $step, question_display_options $options): string {
 517          if ($options->userinfoinhistory && $step->get_user_id() != $options->userinfoinhistory) {
 518              return html_writer::link(
 519                      new moodle_url('/user/view.php', ['id' => $step->get_user_id(), 'course' => $this->page->course->id]),
 520                      $step->get_user_fullname(), ['class' => 'd-table-cell']);
 521          } else {
 522              return '';
 523          }
 524      }
 525  }