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.

Differences Between: [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  namespace mod_quiz\local\reports;
  18  
  19  defined('MOODLE_INTERNAL') || die();
  20  
  21  require_once($CFG->libdir.'/tablelib.php');
  22  
  23  use coding_exception;
  24  use context_module;
  25  use html_writer;
  26  use mod_quiz\quiz_attempt;
  27  use moodle_url;
  28  use popup_action;
  29  use question_state;
  30  use qubaid_condition;
  31  use qubaid_join;
  32  use qubaid_list;
  33  use question_engine_data_mapper;
  34  use stdClass;
  35  
  36  /**
  37   * Base class for the table used by a {@see attempts_report}.
  38   *
  39   * @package   mod_quiz
  40   * @copyright 2010 The Open University
  41   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  42   */
  43  abstract class attempts_report_table extends \table_sql {
  44      public $useridfield = 'userid';
  45  
  46      /** @var moodle_url the URL of this report. */
  47      protected $reporturl;
  48  
  49      /** @var array the display options. */
  50      protected $displayoptions;
  51  
  52      /**
  53       * @var array information about the latest step of each question.
  54       * Loaded by {@see load_question_latest_steps()}, if applicable.
  55       */
  56      protected $lateststeps = null;
  57  
  58      /** @var stdClass the quiz settings for the quiz we are reporting on. */
  59      protected $quiz;
  60  
  61      /** @var context_module the quiz context. */
  62      protected $context;
  63  
  64      /** @var string HTML fragment to select the first/best/last attempt, if appropriate. */
  65      protected $qmsubselect;
  66  
  67      /** @var stdClass attempts_report_options the options affecting this report. */
  68      protected $options;
  69  
  70      /** @var \core\dml\sql_join Contains joins, wheres, params to find students
  71       * in the currently selected group, if applicable.
  72       */
  73      protected $groupstudentsjoins;
  74  
  75      /** @var \core\dml\sql_join Contains joins, wheres, params to find the students in the course. */
  76      protected $studentsjoins;
  77  
  78      /** @var array the questions that comprise this quiz. */
  79      protected $questions;
  80  
  81      /** @var bool whether to include the column with checkboxes to select each attempt. */
  82      protected $includecheckboxes;
  83  
  84      /** @var string The toggle group name for the checkboxes in the checkbox column. */
  85      protected $togglegroup = 'quiz-attempts';
  86  
  87      /** @var string strftime format. */
  88      protected $strtimeformat;
  89  
  90      /** @var bool|null used by {@see col_state()} to cache the has_capability result. */
  91      protected $canreopen = null;
  92  
  93      /**
  94       * Constructor.
  95       *
  96       * @param string $uniqueid
  97       * @param stdClass $quiz
  98       * @param context_module $context
  99       * @param string $qmsubselect
 100       * @param attempts_report_options $options
 101       * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params
 102       * @param \core\dml\sql_join $studentsjoins Contains joins, wheres, params
 103       * @param array $questions
 104       * @param moodle_url $reporturl
 105       */
 106      public function __construct($uniqueid, $quiz, $context, $qmsubselect,
 107              attempts_report_options $options, \core\dml\sql_join $groupstudentsjoins, \core\dml\sql_join $studentsjoins,
 108              $questions, $reporturl) {
 109          parent::__construct($uniqueid);
 110          $this->quiz = $quiz;
 111          $this->context = $context;
 112          $this->qmsubselect = $qmsubselect;
 113          $this->groupstudentsjoins = $groupstudentsjoins;
 114          $this->studentsjoins = $studentsjoins;
 115          $this->questions = $questions;
 116          $this->includecheckboxes = $options->checkboxcolumn;
 117          $this->reporturl = $reporturl;
 118          $this->options = $options;
 119      }
 120  
 121      /**
 122       * Generate the display of the checkbox column.
 123       *
 124       * @param stdClass $attempt the table row being output.
 125       * @return string HTML content to go inside the td.
 126       */
 127      public function col_checkbox($attempt) {
 128          global $OUTPUT;
 129  
 130          if ($attempt->attempt) {
 131              $checkbox = new \core\output\checkbox_toggleall($this->togglegroup, false, [
 132                  'id' => "attemptid_{$attempt->attempt}",
 133                  'name' => 'attemptid[]',
 134                  'value' => $attempt->attempt,
 135                  'label' => get_string('selectattempt', 'quiz'),
 136                  'labelclasses' => 'accesshide',
 137              ]);
 138              return $OUTPUT->render($checkbox);
 139          } else {
 140              return '';
 141          }
 142      }
 143  
 144      /**
 145       * Generate the display of the user's picture column.
 146       *
 147       * @param stdClass $attempt the table row being output.
 148       * @return string HTML content to go inside the td.
 149       */
 150      public function col_picture($attempt) {
 151          global $OUTPUT;
 152          $user = new stdClass();
 153          $additionalfields = explode(',', implode(',', \core_user\fields::get_picture_fields()));
 154          $user = username_load_fields_from_object($user, $attempt, null, $additionalfields);
 155          $user->id = $attempt->userid;
 156          return $OUTPUT->user_picture($user);
 157      }
 158  
 159      /**
 160       * Generate the display of the user's full name column.
 161       *
 162       * @param stdClass $attempt the table row being output.
 163       * @return string HTML content to go inside the td.
 164       */
 165      public function col_fullname($attempt) {
 166          $html = parent::col_fullname($attempt);
 167          if ($this->is_downloading() || empty($attempt->attempt)) {
 168              return $html;
 169          }
 170  
 171          return $html . html_writer::empty_tag('br') . html_writer::link(
 172                  new moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->attempt]),
 173                  get_string('reviewattempt', 'quiz'), ['class' => 'reviewlink']);
 174      }
 175  
 176      /**
 177       * Generate the display of the attempt state column.
 178       *
 179       * @param stdClass $attempt the table row being output.
 180       * @return string HTML content to go inside the td.
 181       */
 182      public function col_state($attempt) {
 183          if (is_null($attempt->attempt)) {
 184              return '-';
 185          }
 186  
 187          $display = quiz_attempt::state_name($attempt->state);
 188          if ($this->is_downloading()) {
 189              return $display;
 190          }
 191  
 192          $this->canreopen ??= has_capability('mod/quiz:reopenattempts', $this->context);
 193          if ($attempt->state == quiz_attempt::ABANDONED && $this->canreopen) {
 194              $display .= ' ' . html_writer::tag('button', get_string('reopenattempt', 'quiz'), [
 195                  'type' => 'button',
 196                  'class' => 'btn btn-secondary',
 197                  'data-action' => 'reopen-attempt',
 198                  'data-attempt-id' => $attempt->attempt,
 199                  'data-after-action-url' => $this->reporturl->out_as_local_url(false),
 200              ]);
 201          }
 202  
 203          return $display;
 204      }
 205  
 206      /**
 207       * Generate the display of the start time column.
 208       *
 209       * @param stdClass $attempt the table row being output.
 210       * @return string HTML content to go inside the td.
 211       */
 212      public function col_timestart($attempt) {
 213          if ($attempt->attempt) {
 214              return userdate($attempt->timestart, $this->strtimeformat);
 215          } else {
 216              return  '-';
 217          }
 218      }
 219  
 220      /**
 221       * Generate the display of the finish time column.
 222       *
 223       * @param stdClass $attempt the table row being output.
 224       * @return string HTML content to go inside the td.
 225       */
 226      public function col_timefinish($attempt) {
 227          if ($attempt->attempt && $attempt->timefinish) {
 228              return userdate($attempt->timefinish, $this->strtimeformat);
 229          } else {
 230              return  '-';
 231          }
 232      }
 233  
 234      /**
 235       * Generate the display of the time taken column.
 236       *
 237       * @param stdClass $attempt the table row being output.
 238       * @return string HTML content to go inside the td.
 239       */
 240      public function col_duration($attempt) {
 241          if ($attempt->timefinish) {
 242              return format_time($attempt->timefinish - $attempt->timestart);
 243          } else {
 244              return '-';
 245          }
 246      }
 247  
 248      /**
 249       * Generate the display of the feedback column.
 250       *
 251       * @param stdClass $attempt the table row being output.
 252       * @return string HTML content to go inside the td.
 253       */
 254      public function col_feedbacktext($attempt) {
 255          if ($attempt->state != quiz_attempt::FINISHED) {
 256              return '-';
 257          }
 258  
 259          $feedback = quiz_report_feedback_for_grade(
 260                  quiz_rescale_grade($attempt->sumgrades, $this->quiz, false),
 261                  $this->quiz->id, $this->context);
 262  
 263          if ($this->is_downloading()) {
 264              $feedback = strip_tags($feedback);
 265          }
 266  
 267          return $feedback;
 268      }
 269  
 270      public function get_row_class($attempt) {
 271          if ($this->qmsubselect && $attempt->gradedattempt) {
 272              return 'gradedattempt';
 273          } else {
 274              return '';
 275          }
 276      }
 277  
 278      /**
 279       * Make a link to review an individual question in a popup window.
 280       *
 281       * @param string $data HTML fragment. The text to make into the link.
 282       * @param stdClass $attempt data for the row of the table being output.
 283       * @param int $slot the number used to identify this question within this usage.
 284       */
 285      public function make_review_link($data, $attempt, $slot) {
 286          global $OUTPUT, $CFG;
 287  
 288          $flag = '';
 289          if ($this->is_flagged($attempt->usageid, $slot)) {
 290              $flag = $OUTPUT->pix_icon('i/flagged', get_string('flagged', 'question'),
 291                      'moodle', ['class' => 'questionflag']);
 292          }
 293  
 294          $feedbackimg = '';
 295          $state = $this->slot_state($attempt, $slot);
 296          if ($state && $state->is_finished() && $state != question_state::$needsgrading) {
 297              $feedbackimg = $this->icon_for_fraction($this->slot_fraction($attempt, $slot));
 298          }
 299  
 300          $output = html_writer::tag('span', $feedbackimg . html_writer::tag('span',
 301                  $data, ['class' => $state->get_state_class(true)]) . $flag, ['class' => 'que']);
 302  
 303          $reviewparams = ['attempt' => $attempt->attempt, 'slot' => $slot];
 304          if (isset($attempt->try)) {
 305              $reviewparams['step'] = $this->step_no_for_try($attempt->usageid, $slot, $attempt->try);
 306          }
 307          $url = new moodle_url('/mod/quiz/reviewquestion.php', $reviewparams);
 308          $output = $OUTPUT->action_link($url, $output,
 309                  new popup_action('click', $url, 'reviewquestion',
 310                          ['height' => 450, 'width' => 650]),
 311                  ['title' => get_string('reviewresponse', 'quiz')]);
 312  
 313          if (!empty($CFG->enableplagiarism)) {
 314              require_once($CFG->libdir . '/plagiarismlib.php');
 315              $output .= plagiarism_get_links([
 316                  'context' => $this->context->id,
 317                  'component' => 'qtype_'.$this->questions[$slot]->qtype,
 318                  'cmid' => $this->context->instanceid,
 319                  'area' => $attempt->usageid,
 320                  'itemid' => $slot,
 321                  'userid' => $attempt->userid]);
 322          }
 323          return $output;
 324      }
 325  
 326      /**
 327       * Get the question attempt state for a particular question in a particular quiz attempt.
 328       *
 329       * @param stdClass $attempt the row data.
 330       * @param int $slot indicates which question.
 331       * @return question_state the state of that question.
 332       */
 333      protected function slot_state($attempt, $slot) {
 334          $stepdata = $this->lateststeps[$attempt->usageid][$slot];
 335          return question_state::get($stepdata->state);
 336      }
 337  
 338      /**
 339       * Work out if a particular question in a particular attempt has been flagged.
 340       *
 341       * @param int $questionusageid used to identify the attempt of interest.
 342       * @param int $slot identifies which question in the attempt to check.
 343       * @return bool true if the question is flagged in the attempt.
 344       */
 345      protected function is_flagged($questionusageid, $slot) {
 346          $stepdata = $this->lateststeps[$questionusageid][$slot];
 347          return $stepdata->flagged;
 348      }
 349  
 350      /**
 351       * Get the mark (out of 1) for the question in a particular slot.
 352       *
 353       * @param stdClass $attempt the row data
 354       * @param int $slot which slot to check.
 355       * @return float the score for this question on a scale of 0 - 1.
 356       */
 357      protected function slot_fraction($attempt, $slot) {
 358          $stepdata = $this->lateststeps[$attempt->usageid][$slot];
 359          return $stepdata->fraction;
 360      }
 361  
 362      /**
 363       * Return an appropriate icon (green tick, red cross, etc.) for a grade.
 364       *
 365       * @param float $fraction grade on a scale 0..1.
 366       * @return string html fragment.
 367       */
 368      protected function icon_for_fraction($fraction) {
 369          global $OUTPUT;
 370  
 371          $feedbackclass = question_state::graded_state_for_fraction($fraction)->get_feedback_class();
 372          return $OUTPUT->pix_icon('i/grade_' . $feedbackclass, get_string($feedbackclass, 'question'),
 373                  'moodle', ['class' => 'icon']);
 374      }
 375  
 376      /**
 377       * Load any extra data after main query.
 378       *
 379       * At this point you can call {@see get_qubaids_condition} to get the condition
 380       * that limits the query to just the question usages shown in this report page or
 381       * alternatively for all attempts if downloading a full report.
 382       */
 383      protected function load_extra_data() {
 384          $this->lateststeps = $this->load_question_latest_steps();
 385      }
 386  
 387      /**
 388       * Load information about the latest state of selected questions in selected attempts.
 389       *
 390       * The results are returned as a two-dimensional array $qubaid => $slot => $dataobject.
 391       *
 392       * @param qubaid_condition|null $qubaids used to restrict which usages are included
 393       *      in the query. See {@see qubaid_condition}.
 394       * @return array of records. See the SQL in this function to see the fields available.
 395       */
 396      protected function load_question_latest_steps(qubaid_condition $qubaids = null) {
 397          if ($qubaids === null) {
 398              $qubaids = $this->get_qubaids_condition();
 399          }
 400          $dm = new question_engine_data_mapper();
 401          $latesstepdata = $dm->load_questions_usages_latest_steps(
 402                  $qubaids, array_keys($this->questions));
 403  
 404          $lateststeps = [];
 405          foreach ($latesstepdata as $step) {
 406              $lateststeps[$step->questionusageid][$step->slot] = $step;
 407          }
 408  
 409          return $lateststeps;
 410      }
 411  
 412      /**
 413       * Does this report require loading any more data after the main query.
 414       *
 415       * @return bool should {@see query_db()} call {@see load_extra_data}?
 416       */
 417      protected function requires_extra_data() {
 418          return $this->requires_latest_steps_loaded();
 419      }
 420  
 421      /**
 422       * Does this report require the detailed information for each question from the question_attempts_steps table?
 423       *
 424       * @return bool should {@see load_extra_data} call {@see load_question_latest_steps}?
 425       */
 426      protected function requires_latest_steps_loaded() {
 427          return false;
 428      }
 429  
 430      /**
 431       * Is this a column that depends on joining to the latest state information?
 432       *
 433       * If so, return the corresponding slot. If not, return false.
 434       *
 435       * @param string $column a column name
 436       * @return int|false false if no, else a slot.
 437       */
 438      protected function is_latest_step_column($column) {
 439          return false;
 440      }
 441  
 442      /**
 443       * Get any fields that might be needed when sorting on date for a particular slot.
 444       *
 445       * Note: these values are only used for sorting. The values displayed are taken
 446       * from $this->lateststeps loaded in load_extra_data().
 447       *
 448       * @param int $slot the slot for the column we want.
 449       * @param string $alias the table alias for latest state information relating to that slot.
 450       * @return string definitions of extra fields to add to the SELECT list of the query.
 451       */
 452      protected function get_required_latest_state_fields($slot, $alias) {
 453          return '';
 454      }
 455  
 456      /**
 457       * Contruct all the parts of the main database query.
 458       *
 459       * @param \core\dml\sql_join $allowedstudentsjoins (joins, wheres, params) defines allowed users for the report.
 460       * @return array with 4 elements [$fields, $from, $where, $params] that can be used to
 461       *     build the actual database query.
 462       */
 463      public function base_sql(\core\dml\sql_join $allowedstudentsjoins) {
 464          global $DB;
 465  
 466          // Please note this uniqueid column is not the same as quiza.uniqueid.
 467          $fields = 'DISTINCT ' . $DB->sql_concat('u.id', "'#'", 'COALESCE(quiza.attempt, 0)') . ' AS uniqueid,';
 468  
 469          if ($this->qmsubselect) {
 470              $fields .= "\n(CASE WHEN $this->qmsubselect THEN 1 ELSE 0 END) AS gradedattempt,";
 471          }
 472  
 473          $userfieldsapi = \core_user\fields::for_identity($this->context)->with_name()
 474                  ->excluding('id', 'idnumber', 'picture', 'imagealt', 'institution', 'department', 'email');
 475          $userfields = $userfieldsapi->get_sql('u', true, '', '', false);
 476  
 477          $fields .= '
 478                  quiza.uniqueid AS usageid,
 479                  quiza.id AS attempt,
 480                  u.id AS userid,
 481                  u.idnumber,
 482                  u.picture,
 483                  u.imagealt,
 484                  u.institution,
 485                  u.department,
 486                  u.email,' . $userfields->selects . ',
 487                  quiza.state,
 488                  quiza.sumgrades,
 489                  quiza.timefinish,
 490                  quiza.timestart,
 491                  CASE WHEN quiza.timefinish = 0 THEN null
 492                       WHEN quiza.timefinish > quiza.timestart THEN quiza.timefinish - quiza.timestart
 493                       ELSE 0 END AS duration';
 494              // To explain that last bit, timefinish can be non-zero and less
 495              // than timestart when you have two load-balanced servers with very
 496              // badly synchronised clocks, and a student does a really quick attempt.
 497  
 498          // This part is the same for all cases. Join the users and quiz_attempts tables.
 499          $from = " {user} u";
 500          $from .= "\n{$userfields->joins}";
 501          $from .= "\nLEFT JOIN {quiz_attempts} quiza ON
 502                                      quiza.userid = u.id AND quiza.quiz = :quizid";
 503          $params = array_merge($userfields->params, ['quizid' => $this->quiz->id]);
 504  
 505          if ($this->qmsubselect && $this->options->onlygraded) {
 506              $from .= " AND (quiza.state <> :finishedstate OR $this->qmsubselect)";
 507              $params['finishedstate'] = quiz_attempt::FINISHED;
 508          }
 509  
 510          switch ($this->options->attempts) {
 511              case attempts_report::ALL_WITH:
 512                  // Show all attempts, including students who are no longer in the course.
 513                  $where = 'quiza.id IS NOT NULL AND quiza.preview = 0';
 514                  break;
 515              case attempts_report::ENROLLED_WITH:
 516                  // Show only students with attempts.
 517                  $from .= "\n" . $allowedstudentsjoins->joins;
 518                  $where = "quiza.preview = 0 AND quiza.id IS NOT NULL AND " . $allowedstudentsjoins->wheres;
 519                  $params = array_merge($params, $allowedstudentsjoins->params);
 520                  break;
 521              case attempts_report::ENROLLED_WITHOUT:
 522                  // Show only students without attempts.
 523                  $from .= "\n" . $allowedstudentsjoins->joins;
 524                  $where = "quiza.id IS NULL AND " . $allowedstudentsjoins->wheres;
 525                  $params = array_merge($params, $allowedstudentsjoins->params);
 526                  break;
 527              case attempts_report::ENROLLED_ALL:
 528                  // Show all students with or without attempts.
 529                  $from .= "\n" . $allowedstudentsjoins->joins;
 530                  $where = "(quiza.preview = 0 OR quiza.preview IS NULL) AND " . $allowedstudentsjoins->wheres;
 531                  $params = array_merge($params, $allowedstudentsjoins->params);
 532                  break;
 533          }
 534  
 535          if ($this->options->states) {
 536              [$statesql, $stateparams] = $DB->get_in_or_equal($this->options->states,
 537                      SQL_PARAMS_NAMED, 'state');
 538              $params += $stateparams;
 539              $where .= " AND (quiza.state $statesql OR quiza.state IS NULL)";
 540          }
 541  
 542          return [$fields, $from, $where, $params];
 543      }
 544  
 545      /**
 546       * Lets subclasses modify the SQL after the count query has been created and before the full query is.
 547       *
 548       * @param string $fields SELECT list.
 549       * @param string $from JOINs part of the SQL.
 550       * @param string $where WHERE clauses.
 551       * @param array $params Query params.
 552       * @return array with 4 elements ($fields, $from, $where, $params) as from base_sql.
 553       */
 554      protected function update_sql_after_count($fields, $from, $where, $params) {
 555          return [$fields, $from, $where, $params];
 556      }
 557  
 558      /**
 559       * Set up the SQL queries (count rows, and get data).
 560       *
 561       * @param \core\dml\sql_join $allowedjoins (joins, wheres, params) defines allowed users for the report.
 562       */
 563      public function setup_sql_queries($allowedjoins) {
 564          [$fields, $from, $where, $params] = $this->base_sql($allowedjoins);
 565  
 566          // The WHERE clause is vital here, because some parts of tablelib.php will expect to
 567          // add bits like ' AND x = 1' on the end, and that needs to leave to valid SQL.
 568          $this->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params);
 569  
 570          [$fields, $from, $where, $params] = $this->update_sql_after_count($fields, $from, $where, $params);
 571          $this->set_sql($fields, $from, $where, $params);
 572      }
 573  
 574      /**
 575       * Add the information about the latest state of the question with slot
 576       * $slot to the query.
 577       *
 578       * The extra information is added as a join to a
 579       * 'table' with alias qa$slot, with columns that are a union of
 580       * the columns of the question_attempts and question_attempts_states tables.
 581       *
 582       * @param int $slot the question to add information for.
 583       */
 584      protected function add_latest_state_join($slot) {
 585          $alias = 'qa' . $slot;
 586  
 587          $fields = $this->get_required_latest_state_fields($slot, $alias);
 588          if (!$fields) {
 589              return;
 590          }
 591  
 592          // This condition roughly filters the list of attempts to be considered.
 593          // It is only used in a sub-select to help crappy databases (see MDL-30122)
 594          // therefore, it is better to use a very simple join, which may include
 595          // too many records, than to do a super-accurate join.
 596          $qubaids = new qubaid_join("{quiz_attempts} {$alias}quiza", "{$alias}quiza.uniqueid",
 597                  "{$alias}quiza.quiz = :{$alias}quizid", ["{$alias}quizid" => $this->sql->params['quizid']]);
 598  
 599          $dm = new question_engine_data_mapper();
 600          [$inlineview, $viewparams] = $dm->question_attempt_latest_state_view($alias, $qubaids);
 601  
 602          $this->sql->fields .= ",\n$fields";
 603          $this->sql->from .= "\nLEFT JOIN $inlineview ON " .
 604                  "$alias.questionusageid = quiza.uniqueid AND $alias.slot = :{$alias}slot";
 605          $this->sql->params[$alias . 'slot'] = $slot;
 606          $this->sql->params = array_merge($this->sql->params, $viewparams);
 607      }
 608  
 609      /**
 610       * Get an appropriate qubaid_condition for loading more data about the attempts we are displaying.
 611       *
 612       * @return qubaid_condition
 613       */
 614      protected function get_qubaids_condition() {
 615          if (is_null($this->rawdata)) {
 616              throw new coding_exception(
 617                      'Cannot call get_qubaids_condition until the main data has been loaded.');
 618          }
 619  
 620          if ($this->is_downloading()) {
 621              // We want usages for all attempts.
 622              return new qubaid_join("(
 623                  SELECT DISTINCT quiza.uniqueid
 624                    FROM " . $this->sql->from . "
 625                   WHERE " . $this->sql->where . "
 626                      ) quizasubquery", 'quizasubquery.uniqueid',
 627                      "1 = 1", $this->sql->params);
 628          }
 629  
 630          $qubaids = [];
 631          foreach ($this->rawdata as $attempt) {
 632              if ($attempt->usageid > 0) {
 633                  $qubaids[] = $attempt->usageid;
 634              }
 635          }
 636  
 637          return new qubaid_list($qubaids);
 638      }
 639  
 640      public function query_db($pagesize, $useinitialsbar = true) {
 641          $doneslots = [];
 642          foreach ($this->get_sort_columns() as $column => $notused) {
 643              $slot = $this->is_latest_step_column($column);
 644              if ($slot && !in_array($slot, $doneslots)) {
 645                  $this->add_latest_state_join($slot);
 646                  $doneslots[] = $slot;
 647              }
 648          }
 649  
 650          parent::query_db($pagesize, $useinitialsbar);
 651  
 652          if ($this->requires_extra_data()) {
 653              $this->load_extra_data();
 654          }
 655      }
 656  
 657      public function get_sort_columns() {
 658          // Add attemptid as a final tie-break to the sort. This ensures that
 659          // Attempts by the same student appear in order when just sorting by name.
 660          $sortcolumns = parent::get_sort_columns();
 661          $sortcolumns['quiza.id'] = SORT_ASC;
 662          return $sortcolumns;
 663      }
 664  
 665      public function wrap_html_start() {
 666          if ($this->is_downloading() || !$this->includecheckboxes) {
 667              return;
 668          }
 669  
 670          $url = $this->options->get_url();
 671          $url->param('sesskey', sesskey());
 672  
 673          echo '<div id="tablecontainer">';
 674          echo '<form id="attemptsform" method="post" action="' . $url->out_omit_querystring() . '">';
 675  
 676          echo html_writer::input_hidden_params($url);
 677          echo '<div>';
 678      }
 679  
 680      public function wrap_html_finish() {
 681          global $PAGE;
 682          if ($this->is_downloading() || !$this->includecheckboxes) {
 683              return;
 684          }
 685  
 686          echo '<div id="commands">';
 687          $this->submit_buttons();
 688          echo '</div>';
 689  
 690          // Close the form.
 691          echo '</div>';
 692          echo '</form></div>';
 693      }
 694  
 695      /**
 696       * Output any submit buttons required by the $this->includecheckboxes form.
 697       */
 698      protected function submit_buttons() {
 699          global $PAGE;
 700          if (has_capability('mod/quiz:deleteattempts', $this->context)) {
 701              $deletebuttonparams = [
 702                  'type'  => 'submit',
 703                  'class' => 'btn btn-secondary mr-1',
 704                  'id'    => 'deleteattemptsbutton',
 705                  'name'  => 'delete',
 706                  'value' => get_string('deleteselected', 'quiz_overview'),
 707                  'data-action' => 'toggle',
 708                  'data-togglegroup' => $this->togglegroup,
 709                  'data-toggle' => 'action',
 710                  'disabled' => true
 711              ];
 712              echo html_writer::empty_tag('input', $deletebuttonparams);
 713              $PAGE->requires->event_handler('#deleteattemptsbutton', 'click', 'M.util.show_confirm_dialog',
 714                      ['message' => get_string('deleteattemptcheck', 'quiz')]);
 715          }
 716      }
 717  
 718      /**
 719       * Generates the contents for the checkbox column header.
 720       *
 721       * It returns the HTML for a master \core\output\checkbox_toggleall component that selects/deselects all quiz attempts.
 722       *
 723       * @param string $columnname The name of the checkbox column.
 724       * @return string
 725       */
 726      public function checkbox_col_header(string $columnname) {
 727          global $OUTPUT;
 728  
 729          // Make sure to disable sorting on this column.
 730          $this->no_sorting($columnname);
 731  
 732          // Build the select/deselect all control.
 733          $selectallid = $this->uniqueid . '-selectall-attempts';
 734          $selectalltext = get_string('selectall', 'quiz');
 735          $deselectalltext = get_string('selectnone', 'quiz');
 736          $mastercheckbox = new \core\output\checkbox_toggleall($this->togglegroup, true, [
 737              'id' => $selectallid,
 738              'name' => $selectallid,
 739              'value' => 1,
 740              'label' => $selectalltext,
 741              'labelclasses' => 'accesshide',
 742              'selectall' => $selectalltext,
 743              'deselectall' => $deselectalltext,
 744          ]);
 745  
 746          return $OUTPUT->render($mastercheckbox);
 747      }
 748  }