Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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