Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
   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  namespace mod_quiz\output;
  18  
  19  use cm_info;
  20  use coding_exception;
  21  use context;
  22  use context_module;
  23  use html_table;
  24  use html_table_cell;
  25  use html_writer;
  26  use mod_quiz\access_manager;
  27  use mod_quiz\form\preflight_check_form;
  28  use mod_quiz\question\display_options;
  29  use mod_quiz\quiz_attempt;
  30  use moodle_url;
  31  use plugin_renderer_base;
  32  use popup_action;
  33  use question_display_options;
  34  use mod_quiz\quiz_settings;
  35  use renderable;
  36  use single_button;
  37  use stdClass;
  38  
  39  /**
  40   * The main renderer for the quiz module.
  41   *
  42   * @package   mod_quiz
  43   * @category  output
  44   * @copyright 2011 The Open University
  45   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  46   */
  47  class renderer extends plugin_renderer_base {
  48      /**
  49       * Builds the review page
  50       *
  51       * @param quiz_attempt $attemptobj an instance of quiz_attempt.
  52       * @param array $slots of slots to be displayed.
  53       * @param int $page the current page number
  54       * @param bool $showall whether to show entire attempt on one page.
  55       * @param bool $lastpage if true the current page is the last page.
  56       * @param display_options $displayoptions instance of display_options.
  57       * @param array $summarydata contains all table data
  58       * @return string HTML to display.
  59       */
  60      public function review_page(quiz_attempt $attemptobj, $slots, $page, $showall,
  61              $lastpage, display_options $displayoptions, $summarydata) {
  62  
  63          $output = '';
  64          $output .= $this->header();
  65          $output .= $this->review_summary_table($summarydata, $page);
  66          $output .= $this->review_form($page, $showall, $displayoptions,
  67                  $this->questions($attemptobj, true, $slots, $page, $showall, $displayoptions),
  68                  $attemptobj);
  69  
  70          $output .= $this->review_next_navigation($attemptobj, $page, $lastpage, $showall);
  71          $output .= $this->footer();
  72          return $output;
  73      }
  74  
  75      /**
  76       * Renders the review question pop-up.
  77       *
  78       * @param quiz_attempt $attemptobj an instance of quiz_attempt.
  79       * @param int $slot which question to display.
  80       * @param int $seq which step of the question attempt to show. null = latest.
  81       * @param display_options $displayoptions instance of display_options.
  82       * @param array $summarydata contains all table data
  83       * @return string HTML to display.
  84       */
  85      public function review_question_page(quiz_attempt $attemptobj, $slot, $seq,
  86              display_options $displayoptions, $summarydata) {
  87  
  88          $output = '';
  89          $output .= $this->header();
  90          $output .= $this->review_summary_table($summarydata, 0);
  91  
  92          if (!is_null($seq)) {
  93              $output .= $attemptobj->render_question_at_step($slot, $seq, true, $this);
  94          } else {
  95              $output .= $attemptobj->render_question($slot, true, $this);
  96          }
  97  
  98          $output .= $this->close_window_button();
  99          $output .= $this->footer();
 100          return $output;
 101      }
 102  
 103      /**
 104       * Renders the review question pop-up.
 105       *
 106       * @param quiz_attempt $attemptobj an instance of quiz_attempt.
 107       * @param string $message Why the review is not allowed.
 108       * @return string html to output.
 109       */
 110      public function review_question_not_allowed(quiz_attempt $attemptobj, $message) {
 111          $output = '';
 112          $output .= $this->header();
 113          $output .= $this->heading(format_string($attemptobj->get_quiz_name(), true,
 114                  ["context" => $attemptobj->get_quizobj()->get_context()]));
 115          $output .= $this->notification($message);
 116          $output .= $this->close_window_button();
 117          $output .= $this->footer();
 118          return $output;
 119      }
 120  
 121      /**
 122       * Filters the summarydata array.
 123       *
 124       * @param array $summarydata contains row data for table
 125       * @param int $page the current page number
 126       * @return array updated version of the $summarydata array.
 127       */
 128      protected function filter_review_summary_table($summarydata, $page) {
 129          if ($page == 0) {
 130              return $summarydata;
 131          }
 132  
 133          // Only show some of summary table on subsequent pages.
 134          foreach ($summarydata as $key => $rowdata) {
 135              if (!in_array($key, ['user', 'attemptlist'])) {
 136                  unset($summarydata[$key]);
 137              }
 138          }
 139  
 140          return $summarydata;
 141      }
 142  
 143      /**
 144       * Outputs the table containing data from summary data array
 145       *
 146       * @param array $summarydata contains row data for table
 147       * @param int $page contains the current page number
 148       * @return string HTML to display.
 149       */
 150      public function review_summary_table($summarydata, $page) {
 151          $summarydata = $this->filter_review_summary_table($summarydata, $page);
 152          if (empty($summarydata)) {
 153              return '';
 154          }
 155  
 156          $output = '';
 157          $output .= html_writer::start_tag('table', [
 158                  'class' => 'generaltable generalbox quizreviewsummary']);
 159          $output .= html_writer::start_tag('tbody');
 160          foreach ($summarydata as $rowdata) {
 161              if ($rowdata['title'] instanceof renderable) {
 162                  $title = $this->render($rowdata['title']);
 163              } else {
 164                  $title = $rowdata['title'];
 165              }
 166  
 167              if ($rowdata['content'] instanceof renderable) {
 168                  $content = $this->render($rowdata['content']);
 169              } else {
 170                  $content = $rowdata['content'];
 171              }
 172  
 173              $output .= html_writer::tag('tr',
 174                      html_writer::tag('th', $title, ['class' => 'cell', 'scope' => 'row']) .
 175                      html_writer::tag('td', $content, ['class' => 'cell'])
 176              );
 177          }
 178  
 179          $output .= html_writer::end_tag('tbody');
 180          $output .= html_writer::end_tag('table');
 181          return $output;
 182      }
 183  
 184      /**
 185       * Renders each question
 186       *
 187       * @param quiz_attempt $attemptobj instance of quiz_attempt
 188       * @param bool $reviewing
 189       * @param array $slots array of integers relating to questions
 190       * @param int $page current page number
 191       * @param bool $showall if true shows attempt on single page
 192       * @param display_options $displayoptions instance of display_options
 193       */
 194      public function questions(quiz_attempt $attemptobj, $reviewing, $slots, $page, $showall,
 195              display_options $displayoptions) {
 196          $output = '';
 197          foreach ($slots as $slot) {
 198              $output .= $attemptobj->render_question($slot, $reviewing, $this,
 199                      $attemptobj->review_url($slot, $page, $showall));
 200          }
 201          return $output;
 202      }
 203  
 204      /**
 205       * Renders the main bit of the review page.
 206       *
 207       * @param int $page current page number
 208       * @param bool $showall if true display attempt on one page
 209       * @param display_options $displayoptions instance of display_options
 210       * @param string $content the rendered display of each question
 211       * @param quiz_attempt $attemptobj instance of quiz_attempt
 212       * @return string HTML to display.
 213       */
 214      public function review_form($page, $showall, $displayoptions, $content, $attemptobj) {
 215          if ($displayoptions->flags != question_display_options::EDITABLE) {
 216              return $content;
 217          }
 218  
 219          $this->page->requires->js_init_call('M.mod_quiz.init_review_form', null, false,
 220                  quiz_get_js_module());
 221  
 222          $output = '';
 223          $output .= html_writer::start_tag('form', ['action' => $attemptobj->review_url(null,
 224                  $page, $showall), 'method' => 'post', 'class' => 'questionflagsaveform']);
 225          $output .= html_writer::start_tag('div');
 226          $output .= $content;
 227          $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey',
 228                  'value' => sesskey()]);
 229          $output .= html_writer::start_tag('div', ['class' => 'submitbtns']);
 230          $output .= html_writer::empty_tag('input', ['type' => 'submit',
 231                  'class' => 'questionflagsavebutton btn btn-secondary', 'name' => 'savingflags',
 232                  'value' => get_string('saveflags', 'question')]);
 233          $output .= html_writer::end_tag('div');
 234          $output .= html_writer::end_tag('div');
 235          $output .= html_writer::end_tag('form');
 236  
 237          return $output;
 238      }
 239  
 240      /**
 241       * Returns either a link or button.
 242       *
 243       * @param quiz_attempt $attemptobj instance of quiz_attempt
 244       */
 245      public function finish_review_link(quiz_attempt $attemptobj) {
 246          $url = $attemptobj->view_url();
 247  
 248          if ($attemptobj->get_access_manager(time())->attempt_must_be_in_popup()) {
 249              $this->page->requires->js_init_call('M.mod_quiz.secure_window.init_close_button',
 250                      [$url->out(false)], false, quiz_get_js_module());
 251              return html_writer::empty_tag('input', ['type' => 'button',
 252                      'value' => get_string('finishreview', 'quiz'),
 253                      'id' => 'secureclosebutton',
 254                      'class' => 'mod_quiz-next-nav btn btn-primary']);
 255  
 256          } else {
 257              return html_writer::link($url, get_string('finishreview', 'quiz'),
 258                      ['class' => 'mod_quiz-next-nav']);
 259          }
 260      }
 261  
 262      /**
 263       * Creates the navigation links/buttons at the bottom of the review attempt page.
 264       *
 265       * Note, the name of this function is no longer accurate, but when the design
 266       * changed, it was decided to keep the old name for backwards compatibility.
 267       *
 268       * @param quiz_attempt $attemptobj instance of quiz_attempt
 269       * @param int $page the current page
 270       * @param bool $lastpage if true current page is the last page
 271       * @param bool|null $showall if true, the URL will be to review the entire attempt on one page,
 272       *      and $page will be ignored. If null, a sensible default will be chosen.
 273       *
 274       * @return string HTML fragment.
 275       */
 276      public function review_next_navigation(quiz_attempt $attemptobj, $page, $lastpage, $showall = null) {
 277          $nav = '';
 278          if ($page > 0) {
 279              $nav .= link_arrow_left(get_string('navigateprevious', 'quiz'),
 280                      $attemptobj->review_url(null, $page - 1, $showall), false, 'mod_quiz-prev-nav');
 281          }
 282          if ($lastpage) {
 283              $nav .= $this->finish_review_link($attemptobj);
 284          } else {
 285              $nav .= link_arrow_right(get_string('navigatenext', 'quiz'),
 286                      $attemptobj->review_url(null, $page + 1, $showall), false, 'mod_quiz-next-nav');
 287          }
 288          return html_writer::tag('div', $nav, ['class' => 'submitbtns']);
 289      }
 290  
 291      /**
 292       * Return the HTML of the quiz timer.
 293       *
 294       * @param quiz_attempt $attemptobj instance of quiz_attempt
 295       * @param int $timenow timestamp to use as 'now'.
 296       * @return string HTML content.
 297       */
 298      public function countdown_timer(quiz_attempt $attemptobj, $timenow) {
 299  
 300          $timeleft = $attemptobj->get_time_left_display($timenow);
 301          if ($timeleft !== false) {
 302              $ispreview = $attemptobj->is_preview();
 303              $timerstartvalue = $timeleft;
 304              if (!$ispreview) {
 305                  // Make sure the timer starts just above zero. If $timeleft was <= 0, then
 306                  // this will just have the effect of causing the quiz to be submitted immediately.
 307                  $timerstartvalue = max($timerstartvalue, 1);
 308              }
 309              $this->initialise_timer($timerstartvalue, $ispreview);
 310          }
 311  
 312          return $this->output->render_from_template('mod_quiz/timer', (object) []);
 313      }
 314  
 315      /**
 316       * Create a preview link
 317       *
 318       * @param moodle_url $url URL to restart the attempt.
 319       */
 320      public function restart_preview_button($url) {
 321          return $this->single_button($url, get_string('startnewpreview', 'quiz'));
 322      }
 323  
 324      /**
 325       * Outputs the navigation block panel
 326       *
 327       * @param navigation_panel_base $panel
 328       */
 329      public function navigation_panel(navigation_panel_base $panel) {
 330  
 331          $output = '';
 332          $userpicture = $panel->user_picture();
 333          if ($userpicture) {
 334              $fullname = fullname($userpicture->user);
 335              if ($userpicture->size) {
 336                  $fullname = html_writer::div($fullname);
 337              }
 338              $output .= html_writer::tag('div', $this->render($userpicture) . $fullname,
 339                      ['id' => 'user-picture', 'class' => 'clearfix']);
 340          }
 341          $output .= $panel->render_before_button_bits($this);
 342  
 343          $bcc = $panel->get_button_container_class();
 344          $output .= html_writer::start_tag('div', ['class' => "qn_buttons clearfix $bcc"]);
 345          foreach ($panel->get_question_buttons() as $button) {
 346              $output .= $this->render($button);
 347          }
 348          $output .= html_writer::end_tag('div');
 349  
 350          $output .= html_writer::tag('div', $panel->render_end_bits($this),
 351                  ['class' => 'othernav']);
 352  
 353          $this->page->requires->js_init_call('M.mod_quiz.nav.init', null, false,
 354                  quiz_get_js_module());
 355  
 356          return $output;
 357      }
 358  
 359      /**
 360       * Display a quiz navigation button.
 361       *
 362       * @param navigation_question_button $button
 363       * @return string HTML fragment.
 364       */
 365      protected function render_navigation_question_button(navigation_question_button $button) {
 366          $classes = ['qnbutton', $button->stateclass, $button->navmethod, 'btn'];
 367          $extrainfo = [];
 368  
 369          if ($button->currentpage) {
 370              $classes[] = 'thispage';
 371              $extrainfo[] = get_string('onthispage', 'quiz');
 372          }
 373  
 374          // Flagged?
 375          if ($button->flagged) {
 376              $classes[] = 'flagged';
 377              $flaglabel = get_string('flagged', 'question');
 378          } else {
 379              $flaglabel = '';
 380          }
 381          $extrainfo[] = html_writer::tag('span', $flaglabel, ['class' => 'flagstate']);
 382  
 383          if ($button->isrealquestion) {
 384              $qnostring = 'questionnonav';
 385          } else {
 386              $qnostring = 'questionnonavinfo';
 387          }
 388  
 389          $tooltip = get_string('questionx', 'question', s($button->number)) . ' - ' . $button->statestring;
 390  
 391          $a = new stdClass();
 392          $a->number = s($button->number);
 393          $a->attributes = implode(' ', $extrainfo);
 394          $tagcontents = html_writer::tag('span', '', ['class' => 'thispageholder']) .
 395                  html_writer::tag('span', '', ['class' => 'trafficlight']) .
 396                  get_string($qnostring, 'quiz', $a);
 397          $tagattributes = ['class' => implode(' ', $classes), 'id' => $button->id,
 398                  'title' => $tooltip, 'data-quiz-page' => $button->page];
 399  
 400          if ($button->url) {
 401              return html_writer::link($button->url, $tagcontents, $tagattributes);
 402          } else {
 403              return html_writer::tag('span', $tagcontents, $tagattributes);
 404          }
 405      }
 406  
 407      /**
 408       * Display a quiz navigation heading.
 409       *
 410       * @param navigation_section_heading $heading the heading.
 411       * @return string HTML fragment.
 412       */
 413      protected function render_navigation_section_heading(navigation_section_heading $heading) {
 414          if (empty($heading->heading)) {
 415              $headingtext = get_string('sectionnoname', 'quiz');
 416              $class = ' dimmed_text';
 417          } else {
 418              $headingtext = $heading->heading;
 419              $class = '';
 420          }
 421          return $this->heading($headingtext, 3, 'mod_quiz-section-heading' . $class);
 422      }
 423  
 424      /**
 425       * Renders a list of links the other attempts.
 426       *
 427       * @param links_to_other_attempts $links
 428       * @return string HTML fragment.
 429       */
 430      protected function render_links_to_other_attempts(
 431              links_to_other_attempts $links) {
 432          $attemptlinks = [];
 433          foreach ($links->links as $attempt => $url) {
 434              if (!$url) {
 435                  $attemptlinks[] = html_writer::tag('strong', $attempt);
 436              } else {
 437                  if ($url instanceof renderable) {
 438                      $attemptlinks[] = $this->render($url);
 439                  } else {
 440                      $attemptlinks[] = html_writer::link($url, $attempt);
 441                  }
 442              }
 443          }
 444          return implode(', ', $attemptlinks);
 445      }
 446  
 447      /**
 448       * Render the 'start attempt' page.
 449       *
 450       * The student gets here if their interaction with the preflight check
 451       * from fails in some way (e.g. they typed the wrong password).
 452       *
 453       * @param \mod_quiz\quiz_settings $quizobj
 454       * @param preflight_check_form $mform
 455       * @return string
 456       */
 457      public function start_attempt_page(quiz_settings $quizobj, preflight_check_form $mform) {
 458          $output = '';
 459          $output .= $this->header();
 460          $output .= $this->during_attempt_tertiary_nav($quizobj->view_url());
 461          $output .= $this->heading(format_string($quizobj->get_quiz_name(), true,
 462                  ["context" => $quizobj->get_context()]));
 463          $output .= $this->quiz_intro($quizobj->get_quiz(), $quizobj->get_cm());
 464          $output .= $mform->render();
 465          $output .= $this->footer();
 466          return $output;
 467      }
 468  
 469      /**
 470       * Attempt Page
 471       *
 472       * @param quiz_attempt $attemptobj Instance of quiz_attempt
 473       * @param int $page Current page number
 474       * @param access_manager $accessmanager Instance of access_manager
 475       * @param array $messages An array of messages
 476       * @param array $slots Contains an array of integers that relate to questions
 477       * @param int $id The ID of an attempt
 478       * @param int $nextpage The number of the next page
 479       * @return string HTML to output.
 480       */
 481      public function attempt_page($attemptobj, $page, $accessmanager, $messages, $slots, $id,
 482              $nextpage) {
 483          $output = '';
 484          $output .= $this->header();
 485          $output .= $this->during_attempt_tertiary_nav($attemptobj->view_url());
 486          $output .= $this->quiz_notices($messages);
 487          $output .= $this->countdown_timer($attemptobj, time());
 488          $output .= $this->attempt_form($attemptobj, $page, $slots, $id, $nextpage);
 489          $output .= $this->footer();
 490          return $output;
 491      }
 492  
 493      /**
 494       * Render the tertiary navigation for pages during the attempt.
 495       *
 496       * @param string|moodle_url $quizviewurl url of the view.php page for this quiz.
 497       * @return string HTML to output.
 498       */
 499      public function during_attempt_tertiary_nav($quizviewurl): string {
 500          $output = '';
 501          $output .= html_writer::start_div('container-fluid tertiary-navigation');
 502          $output .= html_writer::start_div('row');
 503          $output .= html_writer::start_div('navitem');
 504          $output .= html_writer::link($quizviewurl, get_string('back'),
 505                  ['class' => 'btn btn-secondary']);
 506          $output .= html_writer::end_div();
 507          $output .= html_writer::end_div();
 508          $output .= html_writer::end_div();
 509          return $output;
 510      }
 511  
 512      /**
 513       * Returns any notices.
 514       *
 515       * @param array $messages
 516       */
 517      public function quiz_notices($messages) {
 518          if (!$messages) {
 519              return '';
 520          }
 521          return $this->notification(
 522                  html_writer::tag('p', get_string('accessnoticesheader', 'quiz')) . $this->access_messages($messages),
 523                  'warning',
 524                  false
 525          );
 526      }
 527  
 528      /**
 529       * Outputs the form for making an attempt
 530       *
 531       * @param quiz_attempt $attemptobj
 532       * @param int $page Current page number
 533       * @param array $slots Array of integers relating to questions
 534       * @param int $id ID of the attempt
 535       * @param int $nextpage Next page number
 536       */
 537      public function attempt_form($attemptobj, $page, $slots, $id, $nextpage) {
 538          $output = '';
 539  
 540          // Start the form.
 541          $output .= html_writer::start_tag('form',
 542                  ['action' => new moodle_url($attemptobj->processattempt_url(),
 543                          ['cmid' => $attemptobj->get_cmid()]), 'method' => 'post',
 544                          'enctype' => 'multipart/form-data', 'accept-charset' => 'utf-8',
 545                          'id' => 'responseform']);
 546          $output .= html_writer::start_tag('div');
 547  
 548          // Print all the questions.
 549          foreach ($slots as $slot) {
 550              $output .= $attemptobj->render_question($slot, false, $this,
 551                      $attemptobj->attempt_url($slot, $page));
 552          }
 553  
 554          $navmethod = $attemptobj->get_quiz()->navmethod;
 555          $output .= $this->attempt_navigation_buttons($page, $attemptobj->is_last_page($page), $navmethod);
 556  
 557          // Some hidden fields to track what is going on.
 558          $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'attempt',
 559                  'value' => $attemptobj->get_attemptid()]);
 560          $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'thispage',
 561                  'value' => $page, 'id' => 'followingpage']);
 562          $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'nextpage',
 563                  'value' => $nextpage]);
 564          $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'timeup',
 565                  'value' => '0', 'id' => 'timeup']);
 566          $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey',
 567                  'value' => sesskey()]);
 568          $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'mdlscrollto',
 569                  'value' => '', 'id' => 'mdlscrollto']);
 570  
 571          // Add a hidden field with questionids. Do this at the end of the form, so
 572          // if you navigate before the form has finished loading, it does not wipe all
 573          // the student's answers.
 574          $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'slots',
 575                  'value' => implode(',', $attemptobj->get_active_slots($page))]);
 576  
 577          // Finish the form.
 578          $output .= html_writer::end_tag('div');
 579          $output .= html_writer::end_tag('form');
 580  
 581          $output .= $this->connection_warning();
 582  
 583          return $output;
 584      }
 585  
 586      /**
 587       * Display the prev/next buttons that go at the bottom of each page of the attempt.
 588       *
 589       * @param int $page the page number. Starts at 0 for the first page.
 590       * @param bool $lastpage is this the last page in the quiz?
 591       * @param string $navmethod Optional quiz attribute, 'free' (default) or 'sequential'
 592       * @return string HTML fragment.
 593       */
 594      protected function attempt_navigation_buttons($page, $lastpage, $navmethod = 'free') {
 595          $output = '';
 596  
 597          $output .= html_writer::start_tag('div', ['class' => 'submitbtns']);
 598          if ($page > 0 && $navmethod == 'free') {
 599              $output .= html_writer::empty_tag('input', ['type' => 'submit', 'name' => 'previous',
 600                      'value' => get_string('navigateprevious', 'quiz'), 'class' => 'mod_quiz-prev-nav btn btn-secondary',
 601                      'id' => 'mod_quiz-prev-nav']);
 602              $this->page->requires->js_call_amd('core_form/submit', 'init', ['mod_quiz-prev-nav']);
 603          }
 604          if ($lastpage) {
 605              $nextlabel = get_string('endtest', 'quiz');
 606          } else {
 607              $nextlabel = get_string('navigatenext', 'quiz');
 608          }
 609          $output .= html_writer::empty_tag('input', ['type' => 'submit', 'name' => 'next',
 610                  'value' => $nextlabel, 'class' => 'mod_quiz-next-nav btn btn-primary', 'id' => 'mod_quiz-next-nav']);
 611          $output .= html_writer::end_tag('div');
 612          $this->page->requires->js_call_amd('core_form/submit', 'init', ['mod_quiz-next-nav']);
 613  
 614          return $output;
 615      }
 616  
 617      /**
 618       * Render a button which allows students to redo a question in the attempt.
 619       *
 620       * @param int $slot the number of the slot to generate the button for.
 621       * @param bool $disabled if true, output the button disabled.
 622       * @return string HTML fragment.
 623       */
 624      public function redo_question_button($slot, $disabled) {
 625          $attributes = ['type' => 'submit', 'name' => 'redoslot' . $slot,
 626                  'value' => get_string('redoquestion', 'quiz'),
 627                  'class' => 'mod_quiz-redo_question_button btn btn-secondary',
 628                  'id' => 'redoslot' . $slot . '-submit',
 629                  'data-savescrollposition' => 'true',
 630              ];
 631          if ($disabled) {
 632              $attributes['disabled'] = 'disabled';
 633          } else {
 634              $this->page->requires->js_call_amd('core_question/question_engine', 'initSubmitButton', [$attributes['id']]);
 635          }
 636          return html_writer::div(html_writer::empty_tag('input', $attributes));
 637      }
 638  
 639      /**
 640       * Initialise the JavaScript required to initialise the countdown timer.
 641       *
 642       * @param int $timerstartvalue time remaining, in seconds.
 643       * @param bool $ispreview true if this is a preview attempt.
 644       */
 645      public function initialise_timer($timerstartvalue, $ispreview) {
 646          $options = [$timerstartvalue, (bool) $ispreview];
 647          $this->page->requires->js_init_call('M.mod_quiz.timer.init', $options, false, quiz_get_js_module());
 648      }
 649  
 650      /**
 651       * Output a page with an optional message, and JavaScript code to close the
 652       * current window and redirect the parent window to a new URL.
 653       *
 654       * @param moodle_url $url the URL to redirect the parent window to.
 655       * @param string $message message to display before closing the window. (optional)
 656       * @return string HTML to output.
 657       */
 658      public function close_attempt_popup($url, $message = '') {
 659          $output = '';
 660          $output .= $this->header();
 661          $output .= $this->box_start();
 662  
 663          if ($message) {
 664              $output .= html_writer::tag('p', $message);
 665              $output .= html_writer::tag('p', get_string('windowclosing', 'quiz'));
 666              $delay = 5;
 667          } else {
 668              $output .= html_writer::tag('p', get_string('pleaseclose', 'quiz'));
 669              $delay = 0;
 670          }
 671          $this->page->requires->js_init_call('M.mod_quiz.secure_window.close',
 672                  [$url, $delay], false, quiz_get_js_module());
 673  
 674          $output .= $this->box_end();
 675          $output .= $this->footer();
 676          return $output;
 677      }
 678  
 679      /**
 680       * Print each message in an array, surrounded by &lt;p>, &lt;/p> tags.
 681       *
 682       * @param array $messages the array of message strings.
 683       * @return string HTML to output.
 684       */
 685      public function access_messages($messages) {
 686          $output = '';
 687          foreach ($messages as $message) {
 688              $output .= html_writer::tag('p', $message, ['class' => 'text-left']);
 689          }
 690          return $output;
 691      }
 692  
 693      /*
 694       * Summary Page
 695       */
 696      /**
 697       * Create the summary page
 698       *
 699       * @param quiz_attempt $attemptobj
 700       * @param display_options $displayoptions
 701       */
 702      public function summary_page($attemptobj, $displayoptions) {
 703          $output = '';
 704          $output .= $this->header();
 705          $output .= $this->during_attempt_tertiary_nav($attemptobj->view_url());
 706          $output .= $this->heading(format_string($attemptobj->get_quiz_name()));
 707          $output .= $this->heading(get_string('summaryofattempt', 'quiz'), 3);
 708          $output .= $this->summary_table($attemptobj, $displayoptions);
 709          $output .= $this->summary_page_controls($attemptobj);
 710          $output .= $this->footer();
 711          return $output;
 712      }
 713  
 714      /**
 715       * Generates the table of summarydata
 716       *
 717       * @param quiz_attempt $attemptobj
 718       * @param display_options $displayoptions
 719       */
 720      public function summary_table($attemptobj, $displayoptions) {
 721          // Prepare the summary table header.
 722          $table = new html_table();
 723          $table->attributes['class'] = 'generaltable quizsummaryofattempt boxaligncenter';
 724          $table->head = [get_string('question', 'quiz'), get_string('status', 'quiz')];
 725          $table->align = ['left', 'left'];
 726          $table->size = ['', ''];
 727          $markscolumn = $displayoptions->marks >= question_display_options::MARK_AND_MAX;
 728          if ($markscolumn) {
 729              $table->head[] = get_string('marks', 'quiz');
 730              $table->align[] = 'left';
 731              $table->size[] = '';
 732          }
 733          $tablewidth = count($table->align);
 734          $table->data = [];
 735  
 736          // Get the summary info for each question.
 737          $slots = $attemptobj->get_slots();
 738          foreach ($slots as $slot) {
 739              // Add a section headings if we need one here.
 740              $heading = $attemptobj->get_heading_before_slot($slot);
 741              if ($heading !== null) {
 742                  // There is a heading here.
 743                  $rowclasses = 'quizsummaryheading';
 744                  if ($heading) {
 745                      $heading = format_string($heading);
 746                  } else {
 747                      if (count($attemptobj->get_quizobj()->get_sections()) > 1) {
 748                          // If this is the start of an unnamed section, and the quiz has more
 749                          // than one section, then add a default heading.
 750                          $heading = get_string('sectionnoname', 'quiz');
 751                          $rowclasses .= ' dimmed_text';
 752                      }
 753                  }
 754                  $cell = new html_table_cell(format_string($heading));
 755                  $cell->header = true;
 756                  $cell->colspan = $tablewidth;
 757                  $table->data[] = [$cell];
 758                  $table->rowclasses[] = $rowclasses;
 759              }
 760  
 761              // Don't display information items.
 762              if (!$attemptobj->is_real_question($slot)) {
 763                  continue;
 764              }
 765  
 766              // Real question, show it.
 767              $flag = '';
 768              if ($attemptobj->is_question_flagged($slot)) {
 769                  // Quiz has custom JS manipulating these image tags - so we can't use the pix_icon method here.
 770                  $flag = html_writer::empty_tag('img', ['src' => $this->image_url('i/flagged'),
 771                          'alt' => get_string('flagged', 'question'), 'class' => 'questionflag icon-post']);
 772              }
 773              if ($attemptobj->can_navigate_to($slot)) {
 774                  $row = [html_writer::link($attemptobj->attempt_url($slot),
 775                          $attemptobj->get_question_number($slot) . $flag),
 776                          $attemptobj->get_question_status($slot, $displayoptions->correctness)];
 777              } else {
 778                  $row = [$attemptobj->get_question_number($slot) . $flag,
 779                          $attemptobj->get_question_status($slot, $displayoptions->correctness)];
 780              }
 781              if ($markscolumn) {
 782                  $row[] = $attemptobj->get_question_mark($slot);
 783              }
 784              $table->data[] = $row;
 785              $table->rowclasses[] = 'quizsummary' . $slot . ' ' . $attemptobj->get_question_state_class(
 786                              $slot, $displayoptions->correctness);
 787          }
 788  
 789          // Print the summary table.
 790          return html_writer::table($table);
 791      }
 792  
 793      /**
 794       * Creates any controls the page should have.
 795       *
 796       * @param quiz_attempt $attemptobj
 797       */
 798      public function summary_page_controls($attemptobj) {
 799          $output = '';
 800  
 801          // Return to place button.
 802          if ($attemptobj->get_state() == quiz_attempt::IN_PROGRESS) {
 803              $button = new single_button(
 804                      new moodle_url($attemptobj->attempt_url(null, $attemptobj->get_currentpage())),
 805                      get_string('returnattempt', 'quiz'));
 806              $output .= $this->container($this->container($this->render($button),
 807                      'controls'), 'submitbtns mdl-align');
 808          }
 809  
 810          // Finish attempt button.
 811          $options = [
 812                  'attempt' => $attemptobj->get_attemptid(),
 813                  'finishattempt' => 1,
 814                  'timeup' => 0,
 815                  'slots' => '',
 816                  'cmid' => $attemptobj->get_cmid(),
 817                  'sesskey' => sesskey(),
 818          ];
 819  
 820          $button = new single_button(
 821                  new moodle_url($attemptobj->processattempt_url(), $options),
 822                  get_string('submitallandfinish', 'quiz'));
 823          $button->class = 'btn-finishattempt';
 824          $button->formid = 'frm-finishattempt';
 825          if ($attemptobj->get_state() == quiz_attempt::IN_PROGRESS) {
 826              $totalunanswered = 0;
 827              if ($attemptobj->get_quiz()->navmethod == 'free') {
 828                  // Only count the unanswered question if the navigation method is set to free.
 829                  $totalunanswered = $attemptobj->get_number_of_unanswered_questions();
 830              }
 831              $this->page->requires->js_call_amd('mod_quiz/submission_confirmation', 'init', [$totalunanswered]);
 832          }
 833          $button->type = \single_button::BUTTON_PRIMARY;
 834  
 835          $duedate = $attemptobj->get_due_date();
 836          $message = '';
 837          if ($attemptobj->get_state() == quiz_attempt::OVERDUE) {
 838              $message = get_string('overduemustbesubmittedby', 'quiz', userdate($duedate));
 839  
 840          } else {
 841              if ($duedate) {
 842                  $message = get_string('mustbesubmittedby', 'quiz', userdate($duedate));
 843              }
 844          }
 845  
 846          $output .= $this->countdown_timer($attemptobj, time());
 847          $output .= $this->container($message . $this->container(
 848                          $this->render($button), 'controls'), 'submitbtns mdl-align');
 849  
 850          return $output;
 851      }
 852  
 853      /*
 854       * View Page
 855       */
 856      /**
 857       * Generates the view page
 858       *
 859       * @param stdClass $course the course settings row from the database.
 860       * @param stdClass $quiz the quiz settings row from the database.
 861       * @param stdClass $cm the course_module settings row from the database.
 862       * @param context_module $context the quiz context.
 863       * @param view_page $viewobj
 864       * @return string HTML to display
 865       */
 866      public function view_page($course, $quiz, $cm, $context, $viewobj) {
 867          $output = '';
 868  
 869          $output .= $this->view_page_tertiary_nav($viewobj);
 870          $output .= $this->view_information($quiz, $cm, $context, $viewobj->infomessages);
 871          $output .= $this->view_table($quiz, $context, $viewobj);
 872          $output .= $this->view_result_info($quiz, $context, $cm, $viewobj);
 873          $output .= $this->box($this->view_page_buttons($viewobj), 'quizattempt');
 874          return $output;
 875      }
 876  
 877      /**
 878       * Render the tertiary navigation for the view page.
 879       *
 880       * @param view_page $viewobj the information required to display the view page.
 881       * @return string HTML to output.
 882       */
 883      public function view_page_tertiary_nav(view_page $viewobj): string {
 884          $content = '';
 885  
 886          if ($viewobj->buttontext) {
 887              $attemptbtn = $this->start_attempt_button($viewobj->buttontext,
 888                      $viewobj->startattempturl, $viewobj->preflightcheckform,
 889                      $viewobj->popuprequired, $viewobj->popupoptions);
 890              $content .= $attemptbtn;
 891          }
 892  
 893          if ($viewobj->canedit && !$viewobj->quizhasquestions) {
 894              $content .= html_writer::link($viewobj->editurl, get_string('addquestion', 'quiz'),
 895                      ['class' => 'btn btn-secondary']);
 896          }
 897  
 898          if ($content) {
 899              return html_writer::div(html_writer::div($content, 'row'), 'container-fluid tertiary-navigation');
 900          } else {
 901              return '';
 902          }
 903      }
 904  
 905      /**
 906       * Work out, and render, whatever buttons, and surrounding info, should appear
 907       * at the end of the review page.
 908       *
 909       * @param view_page $viewobj the information required to display the view page.
 910       * @return string HTML to output.
 911       */
 912      public function view_page_buttons(view_page $viewobj) {
 913          $output = '';
 914  
 915          if (!$viewobj->quizhasquestions) {
 916              $output .= html_writer::div(
 917                      $this->notification(get_string('noquestions', 'quiz'), 'warning', false),
 918                      'text-left mb-3');
 919          }
 920          $output .= $this->access_messages($viewobj->preventmessages);
 921  
 922          if ($viewobj->showbacktocourse) {
 923              $output .= $this->single_button($viewobj->backtocourseurl,
 924                      get_string('backtocourse', 'quiz'), 'get',
 925                      ['class' => 'continuebutton']);
 926          }
 927  
 928          return $output;
 929      }
 930  
 931      /**
 932       * Generates the view attempt button
 933       *
 934       * @param string $buttontext the label to display on the button.
 935       * @param moodle_url $url The URL to POST to in order to start the attempt.
 936       * @param preflight_check_form|null $preflightcheckform deprecated.
 937       * @param bool $popuprequired whether the attempt needs to be opened in a pop-up.
 938       * @param array $popupoptions the options to use if we are opening a popup.
 939       * @return string HTML fragment.
 940       */
 941      public function start_attempt_button($buttontext, moodle_url $url,
 942              preflight_check_form $preflightcheckform = null,
 943              $popuprequired = false, $popupoptions = null) {
 944  
 945          $button = new single_button($url, $buttontext, 'post', single_button::BUTTON_PRIMARY);
 946          $button->class .= ' quizstartbuttondiv';
 947          if ($popuprequired) {
 948              $button->class .= ' quizsecuremoderequired';
 949          }
 950  
 951          $popupjsoptions = null;
 952          if ($popuprequired && $popupoptions) {
 953              $action = new popup_action('click', $url, 'popup', $popupoptions);
 954              $popupjsoptions = $action->get_js_options();
 955          }
 956  
 957          $this->page->requires->js_call_amd('mod_quiz/preflightcheck', 'init',
 958                  ['.quizstartbuttondiv [type=submit]', get_string('startattempt', 'quiz'),
 959                          '#mod_quiz_preflight_form', $popupjsoptions]);
 960  
 961          return $this->render($button) . ($preflightcheckform ? $preflightcheckform->render() : '');
 962      }
 963  
 964      /**
 965       * Generate a message saying that this quiz has no questions, with a button to
 966       * go to the edit page, if the user has the right capability.
 967       *
 968       * @param bool $canedit can the current user edit the quiz?
 969       * @param moodle_url $editurl URL of the edit quiz page.
 970       * @return string HTML to output.
 971       *
 972       * @deprecated since Moodle 4.0 MDL-71915 - please do not use this function any more.
 973       */
 974      public function no_questions_message($canedit, $editurl) {
 975          debugging('no_questions_message() is deprecated, please use generate_no_questions_message() instead.', DEBUG_DEVELOPER);
 976  
 977          $output = html_writer::start_tag('div', ['class' => 'card text-center mb-3']);
 978          $output .= html_writer::start_tag('div', ['class' => 'card-body']);
 979  
 980          $output .= $this->notification(get_string('noquestions', 'quiz'), 'warning', false);
 981          if ($canedit) {
 982              $output .= $this->single_button($editurl, get_string('editquiz', 'quiz'), 'get');
 983          }
 984          $output .= html_writer::end_tag('div');
 985          $output .= html_writer::end_tag('div');
 986  
 987          return $output;
 988      }
 989  
 990      /**
 991       * Outputs an error message for any guests accessing the quiz
 992       *
 993       * @param stdClass $course the course settings row from the database.
 994       * @param stdClass $quiz the quiz settings row from the database.
 995       * @param stdClass $cm the course_module settings row from the database.
 996       * @param context_module $context the quiz context.
 997       * @param array $messages Array containing any messages
 998       * @param view_page $viewobj
 999       */
1000      public function view_page_guest($course, $quiz, $cm, $context, $messages, $viewobj) {
1001          $output = '';
1002          $output .= $this->view_page_tertiary_nav($viewobj);
1003          $output .= $this->view_information($quiz, $cm, $context, $messages);
1004          $guestno = html_writer::tag('p', get_string('guestsno', 'quiz'));
1005          $liketologin = html_writer::tag('p', get_string('liketologin'));
1006          $referer = get_local_referer(false);
1007          $output .= $this->confirm($guestno . "\n\n" . $liketologin . "\n", get_login_url(), $referer);
1008          return $output;
1009      }
1010  
1011      /**
1012       * Outputs and error message for anyone who is not enrolled on the course.
1013       *
1014       * @param stdClass $course the course settings row from the database.
1015       * @param stdClass $quiz the quiz settings row from the database.
1016       * @param stdClass $cm the course_module settings row from the database.
1017       * @param context_module $context the quiz context.
1018       * @param array $messages Array containing any messages
1019       * @param view_page $viewobj
1020       */
1021      public function view_page_notenrolled($course, $quiz, $cm, $context, $messages, $viewobj) {
1022          global $CFG;
1023          $output = '';
1024          $output .= $this->view_page_tertiary_nav($viewobj);
1025          $output .= $this->view_information($quiz, $cm, $context, $messages);
1026          $youneedtoenrol = html_writer::tag('p', get_string('youneedtoenrol', 'quiz'));
1027          $button = html_writer::tag('p',
1028                  $this->continue_button($CFG->wwwroot . '/course/view.php?id=' . $course->id));
1029          $output .= $this->box($youneedtoenrol . "\n\n" . $button . "\n", 'generalbox', 'notice');
1030          return $output;
1031      }
1032  
1033      /**
1034       * Output the page information
1035       *
1036       * @param stdClass $quiz the quiz settings.
1037       * @param cm_info|stdClass $cm the course_module object.
1038       * @param context $context the quiz context.
1039       * @param array $messages any access messages that should be described.
1040       * @param bool $quizhasquestions does quiz has questions added.
1041       * @return string HTML to output.
1042       */
1043      public function view_information($quiz, $cm, $context, $messages, bool $quizhasquestions = false) {
1044          $output = '';
1045  
1046          // Output any access messages.
1047          if ($messages) {
1048              $output .= $this->box($this->access_messages($messages), 'quizinfo');
1049          }
1050  
1051          // Show number of attempts summary to those who can view reports.
1052          if (has_capability('mod/quiz:viewreports', $context)) {
1053              if ($strattemptnum = $this->quiz_attempt_summary_link_to_reports($quiz, $cm,
1054                      $context)) {
1055                  $output .= html_writer::tag('div', $strattemptnum,
1056                          ['class' => 'quizattemptcounts']);
1057              }
1058          }
1059  
1060          if (has_any_capability(['mod/quiz:manageoverrides', 'mod/quiz:viewoverrides'], $context)) {
1061              if ($overrideinfo = $this->quiz_override_summary_links($quiz, $cm)) {
1062                  $output .= html_writer::tag('div', $overrideinfo, ['class' => 'quizattemptcounts']);
1063              }
1064          }
1065  
1066          return $output;
1067      }
1068  
1069      /**
1070       * Output the quiz intro.
1071       *
1072       * @param stdClass $quiz the quiz settings.
1073       * @param stdClass $cm the course_module object.
1074       * @return string HTML to output.
1075       */
1076      public function quiz_intro($quiz, $cm) {
1077          if (html_is_blank($quiz->intro)) {
1078              return '';
1079          }
1080  
1081          return $this->box(format_module_intro('quiz', $quiz, $cm->id), 'generalbox', 'intro');
1082      }
1083  
1084      /**
1085       * Generates the table heading.
1086       */
1087      public function view_table_heading() {
1088          return $this->heading(get_string('summaryofattempts', 'quiz'), 3);
1089      }
1090  
1091      /**
1092       * Generates the table of data
1093       *
1094       * @param stdClass $quiz the quiz settings.
1095       * @param context_module $context the quiz context.
1096       * @param view_page $viewobj
1097       */
1098      public function view_table($quiz, $context, $viewobj) {
1099          if (!$viewobj->attempts) {
1100              return '';
1101          }
1102  
1103          // Prepare table header.
1104          $table = new html_table();
1105          $table->attributes['class'] = 'generaltable quizattemptsummary';
1106          $table->caption = get_string('summaryofattempts', 'quiz');
1107          $table->captionhide = true;
1108          $table->head = [];
1109          $table->align = [];
1110          $table->size = [];
1111          if ($viewobj->attemptcolumn) {
1112              $table->head[] = get_string('attemptnumber', 'quiz');
1113              $table->align[] = 'center';
1114              $table->size[] = '';
1115          }
1116          $table->head[] = get_string('attemptstate', 'quiz');
1117          $table->align[] = 'left';
1118          $table->size[] = '';
1119          if ($viewobj->markcolumn) {
1120              $table->head[] = get_string('marks', 'quiz') . ' / ' .
1121                      quiz_format_grade($quiz, $quiz->sumgrades);
1122              $table->align[] = 'center';
1123              $table->size[] = '';
1124          }
1125          if ($viewobj->gradecolumn) {
1126              $table->head[] = get_string('gradenoun') . ' / ' .
1127                      quiz_format_grade($quiz, $quiz->grade);
1128              $table->align[] = 'center';
1129              $table->size[] = '';
1130          }
1131          if ($viewobj->canreviewmine) {
1132              $table->head[] = get_string('review', 'quiz');
1133              $table->align[] = 'center';
1134              $table->size[] = '';
1135          }
1136          if ($viewobj->feedbackcolumn) {
1137              $table->head[] = get_string('feedback', 'quiz');
1138              $table->align[] = 'left';
1139              $table->size[] = '';
1140          }
1141  
1142          // One row for each attempt.
1143          foreach ($viewobj->attemptobjs as $attemptobj) {
1144              $attemptoptions = $attemptobj->get_display_options(true);
1145              $row = [];
1146  
1147              // Add the attempt number.
1148              if ($viewobj->attemptcolumn) {
1149                  if ($attemptobj->is_preview()) {
1150                      $row[] = get_string('preview', 'quiz');
1151                  } else {
1152                      $row[] = $attemptobj->get_attempt_number();
1153                  }
1154              }
1155  
1156              $row[] = $this->attempt_state($attemptobj);
1157  
1158              if ($viewobj->markcolumn) {
1159                  if ($attemptoptions->marks >= question_display_options::MARK_AND_MAX &&
1160                          $attemptobj->is_finished()) {
1161                      $row[] = quiz_format_grade($quiz, $attemptobj->get_sum_marks());
1162                  } else {
1163                      $row[] = '';
1164                  }
1165              }
1166  
1167              // Outside the if because we may be showing feedback but not grades.
1168              $attemptgrade = quiz_rescale_grade($attemptobj->get_sum_marks(), $quiz, false);
1169  
1170              if ($viewobj->gradecolumn) {
1171                  if ($attemptoptions->marks >= question_display_options::MARK_AND_MAX &&
1172                          $attemptobj->is_finished()) {
1173  
1174                      // Highlight the highest grade if appropriate.
1175                      if ($viewobj->overallstats && !$attemptobj->is_preview()
1176                              && $viewobj->numattempts > 1 && !is_null($viewobj->mygrade)
1177                              && $attemptobj->get_state() == quiz_attempt::FINISHED
1178                              && $attemptgrade == $viewobj->mygrade
1179                              && $quiz->grademethod == QUIZ_GRADEHIGHEST) {
1180                          $table->rowclasses[$attemptobj->get_attempt_number()] = 'bestrow';
1181                      }
1182  
1183                      $row[] = quiz_format_grade($quiz, $attemptgrade);
1184                  } else {
1185                      $row[] = '';
1186                  }
1187              }
1188  
1189              if ($viewobj->canreviewmine) {
1190                  $row[] = $viewobj->accessmanager->make_review_link($attemptobj->get_attempt(),
1191                          $attemptoptions, $this);
1192              }
1193  
1194              if ($viewobj->feedbackcolumn && $attemptobj->is_finished()) {
1195                  if ($attemptoptions->overallfeedback) {
1196                      $row[] = quiz_feedback_for_grade($attemptgrade, $quiz, $context);
1197                  } else {
1198                      $row[] = '';
1199                  }
1200              }
1201  
1202              if ($attemptobj->is_preview()) {
1203                  $table->data['preview'] = $row;
1204              } else {
1205                  $table->data[$attemptobj->get_attempt_number()] = $row;
1206              }
1207          } // End of loop over attempts.
1208  
1209          $output = '';
1210          $output .= $this->view_table_heading();
1211          $output .= html_writer::table($table);
1212          return $output;
1213      }
1214  
1215      /**
1216       * Generate a brief textual description of the current state of an attempt.
1217       *
1218       * @param quiz_attempt $attemptobj the attempt
1219       * @return string the appropriate lang string to describe the state.
1220       */
1221      public function attempt_state($attemptobj) {
1222          switch ($attemptobj->get_state()) {
1223              case quiz_attempt::IN_PROGRESS:
1224                  return get_string('stateinprogress', 'quiz');
1225  
1226              case quiz_attempt::OVERDUE:
1227                  return get_string('stateoverdue', 'quiz') . html_writer::tag('span',
1228                                  get_string('stateoverduedetails', 'quiz',
1229                                          userdate($attemptobj->get_due_date())),
1230                                  ['class' => 'statedetails']);
1231  
1232              case quiz_attempt::FINISHED:
1233                  return get_string('statefinished', 'quiz') . html_writer::tag('span',
1234                                  get_string('statefinisheddetails', 'quiz',
1235                                          userdate($attemptobj->get_submitted_date())),
1236                                  ['class' => 'statedetails']);
1237  
1238              case quiz_attempt::ABANDONED:
1239                  return get_string('stateabandoned', 'quiz');
1240  
1241              default:
1242                  throw new coding_exception('Unexpected attempt state');
1243          }
1244      }
1245  
1246      /**
1247       * Generates data pertaining to quiz results
1248       *
1249       * @param stdClass $quiz Array containing quiz data
1250       * @param context_module $context The quiz context.
1251       * @param stdClass|cm_info $cm The course module information.
1252       * @param view_page $viewobj
1253       * @return string HTML to display.
1254       */
1255      public function view_result_info($quiz, $context, $cm, $viewobj) {
1256          $output = '';
1257          if (!$viewobj->numattempts && !$viewobj->gradecolumn && is_null($viewobj->mygrade)) {
1258              return $output;
1259          }
1260          $resultinfo = '';
1261  
1262          if ($viewobj->overallstats) {
1263              if ($viewobj->moreattempts) {
1264                  $a = new stdClass();
1265                  $a->method = quiz_get_grading_option_name($quiz->grademethod);
1266                  $a->mygrade = quiz_format_grade($quiz, $viewobj->mygrade);
1267                  $a->quizgrade = quiz_format_grade($quiz, $quiz->grade);
1268                  $resultinfo .= $this->heading(get_string('gradesofar', 'quiz', $a), 3);
1269              } else {
1270                  $a = new stdClass();
1271                  $a->grade = quiz_format_grade($quiz, $viewobj->mygrade);
1272                  $a->maxgrade = quiz_format_grade($quiz, $quiz->grade);
1273                  $a = get_string('outofshort', 'quiz', $a);
1274                  $resultinfo .= $this->heading(get_string('yourfinalgradeis', 'quiz', $a), 3);
1275              }
1276          }
1277  
1278          if ($viewobj->mygradeoverridden) {
1279  
1280              $resultinfo .= html_writer::tag('p', get_string('overriddennotice', 'grades'),
1281                              ['class' => 'overriddennotice']) . "\n";
1282          }
1283          if ($viewobj->gradebookfeedback) {
1284              $resultinfo .= $this->heading(get_string('comment', 'quiz'), 3);
1285              $resultinfo .= html_writer::div($viewobj->gradebookfeedback, 'quizteacherfeedback') . "\n";
1286          }
1287          if ($viewobj->feedbackcolumn) {
1288              $resultinfo .= $this->heading(get_string('overallfeedback', 'quiz'), 3);
1289              $resultinfo .= html_writer::div(
1290                              quiz_feedback_for_grade($viewobj->mygrade, $quiz, $context),
1291                              'quizgradefeedback') . "\n";
1292          }
1293  
1294          if ($resultinfo) {
1295              $output .= $this->box($resultinfo, 'generalbox', 'feedback');
1296          }
1297          return $output;
1298      }
1299  
1300      /**
1301       * Output either a link to the review page for an attempt, or a button to
1302       * open the review in a popup window.
1303       *
1304       * @param moodle_url $url of the target page.
1305       * @param bool $reviewinpopup whether a pop-up is required.
1306       * @param array $popupoptions options to pass to the popup_action constructor.
1307       * @return string HTML to output.
1308       */
1309      public function review_link($url, $reviewinpopup, $popupoptions) {
1310          if ($reviewinpopup) {
1311              $button = new single_button($url, get_string('review', 'quiz'));
1312              $button->add_action(new popup_action('click', $url, 'quizpopup', $popupoptions));
1313              return $this->render($button);
1314  
1315          } else {
1316              return html_writer::link($url, get_string('review', 'quiz'),
1317                      ['title' => get_string('reviewthisattempt', 'quiz')]);
1318          }
1319      }
1320  
1321      /**
1322       * Displayed where there might normally be a review link, to explain why the
1323       * review is not available at this time.
1324       *
1325       * @param string $message optional message explaining why the review is not possible.
1326       * @return string HTML to output.
1327       */
1328      public function no_review_message($message) {
1329          return html_writer::nonempty_tag('span', $message,
1330                  ['class' => 'noreviewmessage']);
1331      }
1332  
1333      /**
1334       * Returns the same as {@see quiz_num_attempt_summary()} but wrapped in a link to the quiz reports.
1335       *
1336       * @param stdClass $quiz the quiz object. Only $quiz->id is used at the moment.
1337       * @param stdClass $cm the cm object. Only $cm->course, $cm->groupmode and $cm->groupingid
1338       * fields are used at the moment.
1339       * @param context $context the quiz context.
1340       * @param bool $returnzero if false (default), when no attempts have been made '' is returned
1341       *      instead of 'Attempts: 0'.
1342       * @param int $currentgroup if there is a concept of current group where this method is being
1343       *      called (e.g. a report) pass it in here. Default 0 which means no current group.
1344       * @return string HTML fragment for the link.
1345       */
1346      public function quiz_attempt_summary_link_to_reports($quiz, $cm, $context,
1347              $returnzero = false, $currentgroup = 0) {
1348          global $CFG;
1349          $summary = quiz_num_attempt_summary($quiz, $cm, $returnzero, $currentgroup);
1350          if (!$summary) {
1351              return '';
1352          }
1353  
1354          require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
1355          $url = new moodle_url('/mod/quiz/report.php', [
1356                  'id' => $cm->id, 'mode' => quiz_report_default_report($context)]);
1357          return html_writer::link($url, $summary);
1358      }
1359  
1360      /**
1361       * Render a summary of the number of group and user overrides, with corresponding links.
1362       *
1363       * @param stdClass $quiz the quiz settings.
1364       * @param cm_info|stdClass $cm the cm object.
1365       * @param int $currentgroup currently selected group, if there is one.
1366       * @return string HTML fragment for the link.
1367       */
1368      public function quiz_override_summary_links(stdClass $quiz, cm_info|stdClass $cm, $currentgroup = 0): string {
1369  
1370          $baseurl = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id]);
1371          $counts = quiz_override_summary($quiz, $cm, $currentgroup);
1372  
1373          $links = [];
1374          if ($counts['group']) {
1375              $links[] = html_writer::link(new moodle_url($baseurl, ['mode' => 'group']),
1376                      get_string('overridessummarygroup', 'quiz', $counts['group']));
1377          }
1378          if ($counts['user']) {
1379              $links[] = html_writer::link(new moodle_url($baseurl, ['mode' => 'user']),
1380                      get_string('overridessummaryuser', 'quiz', $counts['user']));
1381          }
1382  
1383          if (!$links) {
1384              return '';
1385          }
1386  
1387          $links = implode(', ', $links);
1388          switch ($counts['mode']) {
1389              case 'onegroup':
1390                  return get_string('overridessummarythisgroup', 'quiz', $links);
1391  
1392              case 'somegroups':
1393                  return get_string('overridessummaryyourgroups', 'quiz', $links);
1394  
1395              case 'allgroups':
1396                  return get_string('overridessummary', 'quiz', $links);
1397  
1398              default:
1399                  throw new coding_exception('Unexpected mode ' . $counts['mode']);
1400          }
1401      }
1402  
1403      /**
1404       * Outputs a chart.
1405       *
1406       * @param \core\chart_base $chart The chart.
1407       * @param string $title The title to display above the graph.
1408       * @param array $attrs extra container html attributes.
1409       * @return string HTML of the graph.
1410       */
1411      public function chart(\core\chart_base $chart, $title, $attrs = []) {
1412          return $this->heading($title, 3) . html_writer::tag('div',
1413                          $this->render($chart), array_merge(['class' => 'graph'], $attrs));
1414      }
1415  
1416      /**
1417       * Output a graph, or a message saying that GD is required.
1418       *
1419       * @param moodle_url $url the URL of the graph.
1420       * @param string $title the title to display above the graph.
1421       * @return string HTML of the graph.
1422       */
1423      public function graph(moodle_url $url, $title) {
1424          $graph = html_writer::empty_tag('img', ['src' => $url, 'alt' => $title]);
1425  
1426          return $this->heading($title, 3) . html_writer::tag('div', $graph, ['class' => 'graph']);
1427      }
1428  
1429      /**
1430       * Output the connection warning messages, which are initially hidden, and
1431       * only revealed by JavaScript if necessary.
1432       */
1433      public function connection_warning() {
1434          $options = ['filter' => false, 'newlines' => false];
1435          $warning = format_text(get_string('connectionerror', 'quiz'), FORMAT_MARKDOWN, $options);
1436          $ok = format_text(get_string('connectionok', 'quiz'), FORMAT_MARKDOWN, $options);
1437          return html_writer::tag('div', $warning,
1438                          ['id' => 'connection-error', 'style' => 'display: none;', 'role' => 'alert']) .
1439                  html_writer::tag('div', $ok, ['id' => 'connection-ok', 'style' => 'display: none;', 'role' => 'alert']);
1440      }
1441  
1442      /**
1443       * Deprecated version of render_links_to_other_attempts.
1444       *
1445       * @param links_to_other_attempts $links
1446       * @return string HTML fragment.
1447       * @deprecated since Moodle 4.2. Please use render_links_to_other_attempts instead.
1448       * @todo MDL-76612 Final deprecation in Moodle 4.6
1449       */
1450      protected function render_mod_quiz_links_to_other_attempts(links_to_other_attempts $links) {
1451          return $this->render_links_to_other_attempts($links);
1452      }
1453  
1454      /**
1455       * Deprecated version of render_navigation_question_button.
1456       *
1457       * @param navigation_question_button $button
1458       * @return string HTML fragment.
1459       * @deprecated since Moodle 4.2. Please use render_links_to_other_attempts instead.
1460       * @todo MDL-76612 Final deprecation in Moodle 4.6
1461       */
1462      protected function render_quiz_nav_question_button(navigation_question_button $button) {
1463          return $this->render_navigation_question_button($button);
1464      }
1465  
1466      /**
1467       * Deprecated version of render_navigation_section_heading.
1468       *
1469       * @param navigation_section_heading $heading the heading.
1470       * @return string HTML fragment.
1471       * @deprecated since Moodle 4.2. Please use render_links_to_other_attempts instead.
1472       * @todo MDL-76612 Final deprecation in Moodle 4.6
1473       */
1474      protected function render_quiz_nav_section_heading(navigation_section_heading $heading) {
1475          return $this->render_navigation_section_heading($heading);
1476      }
1477  }