Search moodle.org's
Developer Documentation

See Release Notes

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

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * This file defines the quiz overview report class.
  19   *
  20   * @package   quiz_overview
  21   * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  use mod_quiz\local\reports\attempts_report;
  26  use mod_quiz\question\bank\qbank_helper;
  27  use mod_quiz\quiz_attempt;
  28  use mod_quiz\quiz_settings;
  29  
  30  defined('MOODLE_INTERNAL') || die();
  31  
  32  require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_options.php');
  33  require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_form.php');
  34  require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_table.php');
  35  
  36  
  37  /**
  38   * Quiz report subclass for the overview (grades) report.
  39   *
  40   * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
  41   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  42   */
  43  class quiz_overview_report extends attempts_report {
  44  
  45      /**
  46       * @var array|null cached copy of qbank_helper::get_question_structure for use during regrades.
  47       */
  48      protected $structureforregrade = null;
  49  
  50      /**
  51       * @var array|null used during regrades, to cache which new questionid to use for each old on.
  52       *      for random questions, stores oldquestionid => newquestionid.
  53       *      See get_new_question_for_regrade.
  54       */
  55      protected $newquestionidsforold = null;
  56  
  57      public function display($quiz, $cm, $course) {
  58          global $DB, $PAGE;
  59  
  60          list($currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins) = $this->init(
  61                  'overview', 'quiz_overview_settings_form', $quiz, $cm, $course);
  62  
  63          $options = new quiz_overview_options('overview', $quiz, $cm, $course);
  64  
  65          if ($fromform = $this->form->get_data()) {
  66              $options->process_settings_from_form($fromform);
  67  
  68          } else {
  69              $options->process_settings_from_params();
  70          }
  71  
  72          $this->form->set_data($options->get_initial_form_data());
  73  
  74          // Load the required questions.
  75          $questions = quiz_report_get_significant_questions($quiz);
  76          // Prepare for downloading, if applicable.
  77          $courseshortname = format_string($course->shortname, true,
  78                  ['context' => context_course::instance($course->id)]);
  79          $table = new quiz_overview_table($quiz, $this->context, $this->qmsubselect,
  80                  $options, $groupstudentsjoins, $studentsjoins, $questions, $options->get_url());
  81          $filename = quiz_report_download_filename(get_string('overviewfilename', 'quiz_overview'),
  82                  $courseshortname, $quiz->name);
  83          $table->is_downloading($options->download, $filename,
  84                  $courseshortname . ' ' . format_string($quiz->name, true));
  85          if ($table->is_downloading()) {
  86              raise_memory_limit(MEMORY_EXTRA);
  87          }
  88  
  89          $this->hasgroupstudents = false;
  90          if (!empty($groupstudentsjoins->joins)) {
  91              $sql = "SELECT DISTINCT u.id
  92                        FROM {user} u
  93                      $groupstudentsjoins->joins
  94                       WHERE $groupstudentsjoins->wheres";
  95              $this->hasgroupstudents = $DB->record_exists_sql($sql, $groupstudentsjoins->params);
  96          }
  97          $hasstudents = false;
  98          if (!empty($studentsjoins->joins)) {
  99              $sql = "SELECT DISTINCT u.id
 100                      FROM {user} u
 101                      $studentsjoins->joins
 102                      WHERE $studentsjoins->wheres";
 103              $hasstudents = $DB->record_exists_sql($sql, $studentsjoins->params);
 104          }
 105          if ($options->attempts == self::ALL_WITH) {
 106              // This option is only available to users who can access all groups in
 107              // groups mode, so setting allowed to empty (which means all quiz attempts
 108              // are accessible, is not a security porblem.
 109              $allowedjoins = new \core\dml\sql_join();
 110          }
 111  
 112          $this->process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $options->get_url());
 113  
 114          $hasquestions = quiz_has_questions($quiz->id);
 115  
 116          // Start output.
 117          if (!$table->is_downloading()) {
 118              // Only print headers if not asked to download data.
 119              $this->print_standard_header_and_messages($cm, $course, $quiz,
 120                      $options, $currentgroup, $hasquestions, $hasstudents);
 121  
 122              // Print the display options.
 123              $this->form->display();
 124          }
 125  
 126          $hasstudents = $hasstudents && (!$currentgroup || $this->hasgroupstudents);
 127          if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_WITH)) {
 128              // Construct the SQL.
 129              $table->setup_sql_queries($allowedjoins);
 130  
 131              if (!$table->is_downloading()) {
 132                  // Output the regrade buttons.
 133                  if (has_capability('mod/quiz:regrade', $this->context)) {
 134                      $regradesneeded = $this->count_question_attempts_needing_regrade(
 135                              $quiz, $groupstudentsjoins);
 136                      if ($currentgroup) {
 137                          $a= new stdClass();
 138                          $a->groupname = format_string(groups_get_group_name($currentgroup), true, [
 139                              'context' => $this->context,
 140                          ]);
 141                          $a->coursestudents = get_string('participants');
 142                          $a->countregradeneeded = $regradesneeded;
 143                          $regradealldrydolabel =
 144                                  get_string('regradealldrydogroup', 'quiz_overview', $a);
 145                          $regradealldrylabel =
 146                                  get_string('regradealldrygroup', 'quiz_overview', $a);
 147                          $regradealllabel =
 148                                  get_string('regradeallgroup', 'quiz_overview', $a);
 149                      } else {
 150                          $regradealldrydolabel =
 151                                  get_string('regradealldrydo', 'quiz_overview', $regradesneeded);
 152                          $regradealldrylabel =
 153                                  get_string('regradealldry', 'quiz_overview');
 154                          $regradealllabel =
 155                                  get_string('regradeall', 'quiz_overview');
 156                      }
 157                      $displayurl = new moodle_url($options->get_url(), ['sesskey' => sesskey()]);
 158                      echo '<div class="regradebuttons">';
 159                      echo '<form action="'.$displayurl->out_omit_querystring().'">';
 160                      echo '<div>';
 161                      echo html_writer::input_hidden_params($displayurl);
 162                      echo '<input type="submit" class="btn btn-secondary" name="regradeall" value="'.$regradealllabel.'"/>';
 163                      echo '<input type="submit" class="btn btn-secondary ml-1" name="regradealldry" value="' .
 164                              $regradealldrylabel . '"/>';
 165                      if ($regradesneeded) {
 166                          echo '<input type="submit" class="btn btn-secondary ml-1" name="regradealldrydo" value="' .
 167                                  $regradealldrydolabel . '"/>';
 168                      }
 169                      echo '</div>';
 170                      echo '</form>';
 171                      echo '</div>';
 172                  }
 173                  // Print information on the grading method.
 174                  if ($strattempthighlight = quiz_report_highlighting_grading_method(
 175                          $quiz, $this->qmsubselect, $options->onlygraded)) {
 176                      echo '<div class="quizattemptcounts mt-3">' . $strattempthighlight . '</div>';
 177                  }
 178              }
 179  
 180              // Define table columns.
 181              $columns = [];
 182              $headers = [];
 183  
 184              if (!$table->is_downloading() && $options->checkboxcolumn) {
 185                  $columnname = 'checkbox';
 186                  $columns[] = $columnname;
 187                  $headers[] = $table->checkbox_col_header($columnname);
 188              }
 189  
 190              $this->add_user_columns($table, $columns, $headers);
 191              $this->add_state_column($columns, $headers);
 192              $this->add_time_columns($columns, $headers);
 193  
 194              $this->add_grade_columns($quiz, $options->usercanseegrades, $columns, $headers, false);
 195  
 196              if (!$table->is_downloading() && has_capability('mod/quiz:regrade', $this->context) &&
 197                      $this->has_regraded_questions($table->sql->from, $table->sql->where, $table->sql->params)) {
 198                  $columns[] = 'regraded';
 199                  $headers[] = get_string('regrade', 'quiz_overview');
 200              }
 201  
 202              if ($options->slotmarks) {
 203                  foreach ($questions as $slot => $question) {
 204                      $columns[] = 'qsgrade' . $slot;
 205                      $header = get_string('qbrief', 'quiz', $question->number);
 206                      if (!$table->is_downloading()) {
 207                          $header .= '<br />';
 208                      } else {
 209                          $header .= ' ';
 210                      }
 211                      $header .= '/' . quiz_rescale_grade($question->maxmark, $quiz, 'question');
 212                      $headers[] = $header;
 213                  }
 214              }
 215  
 216              $this->set_up_table_columns($table, $columns, $headers, $this->get_base_url(), $options, false);
 217              $table->set_attribute('class', 'generaltable generalbox grades');
 218  
 219              $table->out($options->pagesize, true);
 220          }
 221  
 222          if (!$table->is_downloading() && $options->usercanseegrades) {
 223              $output = $PAGE->get_renderer('mod_quiz');
 224              list($bands, $bandwidth) = self::get_bands_count_and_width($quiz);
 225              $labels = self::get_bands_labels($bands, $bandwidth, $quiz);
 226  
 227              if ($currentgroup && $this->hasgroupstudents) {
 228                  $sql = "SELECT qg.id
 229                            FROM {quiz_grades} qg
 230                            JOIN {user} u on u.id = qg.userid
 231                          {$groupstudentsjoins->joins}
 232                            WHERE qg.quiz = $quiz->id AND {$groupstudentsjoins->wheres}";
 233                  if ($DB->record_exists_sql($sql, $groupstudentsjoins->params)) {
 234                      $data = quiz_report_grade_bands($bandwidth, $bands, $quiz->id, $groupstudentsjoins);
 235                      $chart = self::get_chart($labels, $data);
 236                      $groupname = format_string(groups_get_group_name($currentgroup), true, [
 237                          'context' => $this->context,
 238                      ]);
 239                      $graphname = get_string('overviewreportgraphgroup', 'quiz_overview', $groupname);
 240                      // Numerical range data should display in LTR even for RTL languages.
 241                      echo $output->chart($chart, $graphname, ['dir' => 'ltr']);
 242                  }
 243              }
 244  
 245              if ($DB->record_exists('quiz_grades', ['quiz' => $quiz->id])) {
 246                  $data = quiz_report_grade_bands($bandwidth, $bands, $quiz->id, new \core\dml\sql_join());
 247                  $chart = self::get_chart($labels, $data);
 248                  $graphname = get_string('overviewreportgraph', 'quiz_overview');
 249                  // Numerical range data should display in LTR even for RTL languages.
 250                  echo $output->chart($chart, $graphname, ['dir' => 'ltr']);
 251              }
 252          }
 253          return true;
 254      }
 255  
 256      /**
 257       * Extends parent function processing any submitted actions.
 258       *
 259       * @param stdClass $quiz
 260       * @param stdClass $cm
 261       * @param int $currentgroup
 262       * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params)
 263       * @param \core\dml\sql_join $allowedjoins (joins, wheres, params)
 264       * @param moodle_url $redirecturl
 265       */
 266      protected function process_actions($quiz, $cm, $currentgroup, \core\dml\sql_join $groupstudentsjoins,
 267              \core\dml\sql_join $allowedjoins, $redirecturl) {
 268          parent::process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $redirecturl);
 269  
 270          if (empty($currentgroup) || $this->hasgroupstudents) {
 271              if (optional_param('regrade', 0, PARAM_BOOL) && confirm_sesskey()) {
 272                  if ($attemptids = optional_param_array('attemptid', [], PARAM_INT)) {
 273                      $this->start_regrade($quiz, $cm);
 274                      $this->regrade_attempts($quiz, false, $groupstudentsjoins, $attemptids);
 275                      $this->finish_regrade($redirecturl);
 276                  }
 277              }
 278          }
 279  
 280          if (optional_param('regradeall', 0, PARAM_BOOL) && confirm_sesskey()) {
 281              $this->start_regrade($quiz, $cm);
 282              $this->regrade_attempts($quiz, false, $groupstudentsjoins);
 283              $this->finish_regrade($redirecturl);
 284  
 285          } else if (optional_param('regradealldry', 0, PARAM_BOOL) && confirm_sesskey()) {
 286              $this->start_regrade($quiz, $cm);
 287              $this->regrade_attempts($quiz, true, $groupstudentsjoins);
 288              $this->finish_regrade($redirecturl);
 289  
 290          } else if (optional_param('regradealldrydo', 0, PARAM_BOOL) && confirm_sesskey()) {
 291              $this->start_regrade($quiz, $cm);
 292              $this->regrade_attempts_needing_it($quiz, $groupstudentsjoins);
 293              $this->finish_regrade($redirecturl);
 294          }
 295      }
 296  
 297      /**
 298       * Check necessary capabilities, and start the display of the regrade progress page.
 299       * @param stdClass $quiz the quiz settings.
 300       * @param stdClass $cm the cm object for the quiz.
 301       */
 302      protected function start_regrade($quiz, $cm) {
 303          require_capability('mod/quiz:regrade', $this->context);
 304          $this->print_header_and_tabs(
 305              $cm,
 306              get_course($cm->course),
 307              $quiz,
 308              $this->mode
 309          );
 310      }
 311  
 312      /**
 313       * Finish displaying the regrade progress page.
 314       * @param moodle_url $nexturl where to send the user after the regrade.
 315       * @uses exit. This method never returns.
 316       */
 317      protected function finish_regrade($nexturl) {
 318          global $OUTPUT;
 319          \core\notification::success(get_string('regradecomplete', 'quiz_overview'));
 320          echo $OUTPUT->continue_button($nexturl);
 321          echo $OUTPUT->footer();
 322          die();
 323      }
 324  
 325      /**
 326       * Unlock the session and allow the regrading process to run in the background.
 327       */
 328      protected function unlock_session() {
 329          \core\session\manager::write_close();
 330          ignore_user_abort(true);
 331      }
 332  
 333      /**
 334       * Regrade a particular quiz attempt. Either for real ($dryrun = false), or
 335       * as a pretend regrade to see which fractions would change. The outcome is
 336       * stored in the quiz_overview_regrades table.
 337       *
 338       * Note, $attempt is not upgraded in the database. The caller needs to do that.
 339       * However, $attempt->sumgrades is updated, if this is not a dry run.
 340       *
 341       * @param stdClass $attempt the quiz attempt to regrade.
 342       * @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
 343       * @param array $slots if null, regrade all questions, otherwise, just regrade
 344       *      the questions with those slots.
 345       * @return array messages array with keys slot number, and values reasons why that slot cannot be regraded.
 346       */
 347      public function regrade_attempt($attempt, $dryrun = false, $slots = null): array {
 348          global $DB;
 349          // Need more time for a quiz with many questions.
 350          core_php_time_limit::raise(300);
 351  
 352          $transaction = $DB->start_delegated_transaction();
 353  
 354          $quba = question_engine::load_questions_usage_by_activity($attempt->uniqueid);
 355  
 356          if (is_null($slots)) {
 357              $slots = $quba->get_slots();
 358          }
 359  
 360          $messages = [];
 361          $finished = $attempt->state == quiz_attempt::FINISHED;
 362          foreach ($slots as $slot) {
 363              $qqr = new stdClass();
 364              $qqr->oldfraction = $quba->get_question_fraction($slot);
 365              $otherquestionversion = $this->get_new_question_for_regrade($attempt, $quba, $slot);
 366  
 367              $message = $quba->validate_can_regrade_with_other_version($slot, $otherquestionversion);
 368              if ($message) {
 369                  $messages[$slot] = $message;
 370                  continue;
 371              }
 372  
 373              $quba->regrade_question($slot, $finished, null, $otherquestionversion);
 374  
 375              $qqr->newfraction = $quba->get_question_fraction($slot);
 376  
 377              if (abs($qqr->oldfraction - $qqr->newfraction) > 1e-7) {
 378                  $qqr->questionusageid = $quba->get_id();
 379                  $qqr->slot = $slot;
 380                  $qqr->regraded = empty($dryrun);
 381                  $qqr->timemodified = time();
 382                  $DB->insert_record('quiz_overview_regrades', $qqr, false);
 383              }
 384          }
 385  
 386          if (!$dryrun) {
 387              question_engine::save_questions_usage_by_activity($quba);
 388  
 389              $params = [
 390                'objectid' => $attempt->id,
 391                'relateduserid' => $attempt->userid,
 392                'context' => $this->context,
 393                'other' => [
 394                  'quizid' => $attempt->quiz
 395                ]
 396              ];
 397              $event = \mod_quiz\event\attempt_regraded::create($params);
 398              $event->trigger();
 399          }
 400  
 401          $transaction->allow_commit();
 402  
 403          // Really, PHP should not need this hint, but without this, we just run out of memory.
 404          $quba = null;
 405          $transaction = null;
 406          gc_collect_cycles();
 407          return $messages;
 408      }
 409  
 410      /**
 411       * For use in tests only. Clear the cached regrade data.
 412       */
 413      public function clear_regrade_date_cache(): void {
 414          $this->structureforregrade = null;
 415          $this->newquestionidsforold = null;
 416      }
 417  
 418      /**
 419       * Work out of we should be using a new question version for a particular slot in a regrade.
 420       *
 421       * @param stdClass $attempt the attempt being regraded.
 422       * @param question_usage_by_activity $quba the question_usage corresponding to that.
 423       * @param int $slot which slot is currently being regraded.
 424       * @return question_definition other question version to use for this slot.
 425       */
 426      protected function get_new_question_for_regrade(stdClass $attempt,
 427              question_usage_by_activity $quba, int $slot): question_definition {
 428  
 429          // If the cache is empty, get information about all the slots.
 430          if ($this->structureforregrade === null) {
 431              $this->newquestionidsforold = [];
 432              // Load the data about all the non-random slots now.
 433              $this->structureforregrade = qbank_helper::get_question_structure(
 434                      $attempt->quiz, $this->context);
 435          }
 436  
 437          // Because of 'Redo question in attempt' feature, we need to find the original slot number.
 438          $originalslot = $quba->get_question_attempt_metadata($slot, 'originalslot') ?? $slot;
 439  
 440          // If this is a non-random slot, we will have the right info cached.
 441          if ($this->structureforregrade[$originalslot]->qtype != 'random') {
 442              // This is a non-random slot.
 443              return question_bank::load_question($this->structureforregrade[$originalslot]->questionid);
 444          }
 445  
 446          // We must be dealing with a random question. Check that cache.
 447          $currentquestion = $quba->get_question_attempt($originalslot)->get_question(false);
 448          if (isset($this->newquestionidsforold[$currentquestion->id])) {
 449              return question_bank::load_question($this->newquestionidsforold[$currentquestion->id]);
 450          }
 451  
 452          // This is a random question we have not seen yet. Find the latest version.
 453          $versionsoptions = qbank_helper::get_version_options($currentquestion->id);
 454          $latestversion = reset($versionsoptions);
 455          $this->newquestionidsforold[$currentquestion->id] = $latestversion->questionid;
 456          return question_bank::load_question($latestversion->questionid);
 457      }
 458  
 459      /**
 460       * Regrade attempts for this quiz, exactly which attempts are regraded is
 461       * controlled by the parameters.
 462       *
 463       * @param stdClass $quiz the quiz settings.
 464       * @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
 465       * @param \core\dml\sql_join|null $groupstudentsjoins empty for all attempts, otherwise regrade attempts
 466       * for these users.
 467       * @param array $attemptids blank for all attempts, otherwise only regrade
 468       * attempts whose id is in this list.
 469       */
 470      protected function regrade_attempts($quiz, $dryrun = false,
 471              core\dml\sql_join $groupstudentsjoins = null, $attemptids = []) {
 472          global $DB;
 473          $this->unlock_session();
 474  
 475          $userfieldsapi = \core_user\fields::for_name();
 476          $sql = "SELECT quiza.*, " . $userfieldsapi->get_sql('u', false, '', '', false)->selects . "
 477                    FROM {quiz_attempts} quiza
 478                    JOIN {user} u ON u.id = quiza.userid";
 479          $where = "quiz = :qid AND preview = 0";
 480          $params = ['qid' => $quiz->id];
 481  
 482          if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) {
 483              $sql .= "\n{$groupstudentsjoins->joins}";
 484              $where .= " AND {$groupstudentsjoins->wheres}";
 485              $params += $groupstudentsjoins->params;
 486          }
 487  
 488          if ($attemptids) {
 489              list($attemptidcondition, $attemptidparams) = $DB->get_in_or_equal($attemptids, SQL_PARAMS_NAMED);
 490              $where .= " AND quiza.id $attemptidcondition";
 491              $params += $attemptidparams;
 492          }
 493  
 494          $sql .= "\nWHERE {$where}";
 495          $attempts = $DB->get_records_sql($sql, $params);
 496          if (!$attempts) {
 497              return;
 498          }
 499  
 500          $this->regrade_batch_of_attempts($quiz, $attempts, $dryrun, $groupstudentsjoins);
 501      }
 502  
 503      /**
 504       * Regrade those questions in those attempts that are marked as needing regrading
 505       * in the quiz_overview_regrades table.
 506       * @param stdClass $quiz the quiz settings.
 507       * @param \core\dml\sql_join $groupstudentsjoins empty for all attempts, otherwise regrade attempts
 508       * for these users.
 509       */
 510      protected function regrade_attempts_needing_it($quiz, \core\dml\sql_join $groupstudentsjoins) {
 511          global $DB;
 512          $this->unlock_session();
 513  
 514          $join = '{quiz_overview_regrades} qqr ON qqr.questionusageid = quiza.uniqueid';
 515          $where = "quiza.quiz = :qid AND quiza.preview = 0 AND qqr.regraded = 0";
 516          $params = ['qid' => $quiz->id];
 517  
 518          // Fetch all attempts that need regrading.
 519          if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) {
 520              $join .= "\nJOIN {user} u ON u.id = quiza.userid
 521                      {$groupstudentsjoins->joins}";
 522              $where .= " AND {$groupstudentsjoins->wheres}";
 523              $params += $groupstudentsjoins->params;
 524          }
 525  
 526          $toregrade = $DB->get_recordset_sql("
 527                  SELECT quiza.uniqueid, qqr.slot
 528                    FROM {quiz_attempts} quiza
 529                    JOIN $join
 530                   WHERE $where", $params);
 531  
 532          $attemptquestions = [];
 533          foreach ($toregrade as $row) {
 534              $attemptquestions[$row->uniqueid][] = $row->slot;
 535          }
 536          $toregrade->close();
 537  
 538          if (!$attemptquestions) {
 539              return;
 540          }
 541  
 542          list($uniqueidcondition, $params) = $DB->get_in_or_equal(array_keys($attemptquestions));
 543          $userfieldsapi = \core_user\fields::for_name();
 544          $attempts = $DB->get_records_sql("
 545                  SELECT quiza.*, " . $userfieldsapi->get_sql('u', false, '', '', false)->selects . "
 546                    FROM {quiz_attempts} quiza
 547                    JOIN {user} u ON u.id = quiza.userid
 548                   WHERE quiza.uniqueid $uniqueidcondition
 549                  ", $params);
 550  
 551          foreach ($attempts as $attempt) {
 552              $attempt->regradeonlyslots = $attemptquestions[$attempt->uniqueid];
 553          }
 554  
 555          $this->regrade_batch_of_attempts($quiz, $attempts, false, $groupstudentsjoins);
 556      }
 557  
 558      /**
 559       * This is a helper used by {@link regrade_attempts()} and
 560       * {@link regrade_attempts_needing_it()}.
 561       *
 562       * Given an array of attempts, it regrades them all, or does a dry run.
 563       * Each object in the attempts array must be a row from the quiz_attempts
 564       * table, with the \core_user\fields::for_name() fields from the user table joined in.
 565       * In addition, if $attempt->regradeonlyslots is set, then only those slots
 566       * are regraded, otherwise all slots are regraded.
 567       *
 568       * @param stdClass $quiz the quiz settings.
 569       * @param array $attempts of data from the quiz_attempts table, with extra data as above.
 570       * @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
 571       * @param \core\dml\sql_join $groupstudentsjoins empty for all attempts, otherwise regrade attempts
 572       */
 573      protected function regrade_batch_of_attempts($quiz, array $attempts,
 574              bool $dryrun, \core\dml\sql_join $groupstudentsjoins) {
 575          global $OUTPUT;
 576          $this->clear_regrade_table($quiz, $groupstudentsjoins);
 577  
 578          $progressbar = new progress_bar('quiz_overview_regrade', 500, true);
 579          $a = [
 580              'count' => count($attempts),
 581              'done'  => 0,
 582          ];
 583          foreach ($attempts as $attempt) {
 584              $a['done']++;
 585              $a['attemptnum'] = $attempt->attempt;
 586              $a['name'] = fullname($attempt);
 587              $a['attemptid'] = $attempt->id;
 588              if (!isset($attempt->regradeonlyslots)) {
 589                  $attempt->regradeonlyslots = null;
 590              }
 591              $progressbar->update($a['done'], $a['count'],
 592                      get_string('regradingattemptxofywithdetails', 'quiz_overview', $a));
 593              $messages = $this->regrade_attempt($attempt, $dryrun, $attempt->regradeonlyslots);
 594              if ($messages) {
 595                  $items = [];
 596                  foreach ($messages as $slot => $message) {
 597                      $items[] = get_string('regradingattemptissue', 'quiz_overview',
 598                              ['slot' => $slot, 'reason' => $message]);
 599                  }
 600                  echo $OUTPUT->notification(
 601                          html_writer::tag('p', get_string('regradingattemptxofyproblem', 'quiz_overview', $a)) .
 602                          html_writer::alist($items), \core\output\notification::NOTIFY_WARNING);
 603              }
 604          }
 605          $progressbar->update($a['done'], $a['count'],
 606                  get_string('regradedsuccessfullyxofy', 'quiz_overview', $a));
 607  
 608          if (!$dryrun) {
 609              $this->update_overall_grades($quiz);
 610          }
 611      }
 612  
 613      /**
 614       * Count the number of attempts in need of a regrade.
 615       *
 616       * @param stdClass $quiz the quiz settings.
 617       * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params) If this is given, only data relating
 618       * to these users is cleared.
 619       * @return int the number of attempts.
 620       */
 621      protected function count_question_attempts_needing_regrade($quiz, \core\dml\sql_join $groupstudentsjoins) {
 622          global $DB;
 623  
 624          $userjoin = '';
 625          $usertest = '';
 626          $params = [];
 627          if ($this->hasgroupstudents) {
 628              $userjoin = "JOIN {user} u ON u.id = quiza.userid
 629                      {$groupstudentsjoins->joins}";
 630              $usertest = "{$groupstudentsjoins->wheres} AND u.id = quiza.userid AND ";
 631              $params = $groupstudentsjoins->params;
 632          }
 633  
 634          $params['cquiz'] = $quiz->id;
 635          $sql = "SELECT COUNT(DISTINCT quiza.id)
 636                    FROM {quiz_attempts} quiza
 637                    JOIN {quiz_overview_regrades} qqr ON quiza.uniqueid = qqr.questionusageid
 638                  $userjoin
 639                   WHERE
 640                        $usertest
 641                        quiza.quiz = :cquiz AND
 642                        quiza.preview = 0 AND
 643                        qqr.regraded = 0";
 644          return $DB->count_records_sql($sql, $params);
 645      }
 646  
 647      /**
 648       * Are there any pending regrades in the table we are going to show?
 649       * @param string $from tables used by the main query.
 650       * @param string $where where clause used by the main query.
 651       * @param array $params required by the SQL.
 652       * @return bool whether there are pending regrades.
 653       */
 654      protected function has_regraded_questions($from, $where, $params) {
 655          global $DB;
 656          return $DB->record_exists_sql("
 657                  SELECT 1
 658                    FROM {$from}
 659                    JOIN {quiz_overview_regrades} qor ON qor.questionusageid = quiza.uniqueid
 660                   WHERE {$where}", $params);
 661      }
 662  
 663      /**
 664       * Remove all information about pending/complete regrades from the database.
 665       * @param stdClass $quiz the quiz settings.
 666       * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params). If this is given, only data relating
 667       * to these users is cleared.
 668       */
 669      protected function clear_regrade_table($quiz, \core\dml\sql_join $groupstudentsjoins) {
 670          global $DB;
 671  
 672          // Fetch all attempts that need regrading.
 673          $select = "questionusageid IN (
 674                      SELECT uniqueid
 675                        FROM {quiz_attempts} quiza";
 676          $where = "WHERE quiza.quiz = :qid";
 677          $params = ['qid' => $quiz->id];
 678          if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) {
 679              $select .= "\nJOIN {user} u ON u.id = quiza.userid
 680                      {$groupstudentsjoins->joins}";
 681              $where .= " AND {$groupstudentsjoins->wheres}";
 682              $params += $groupstudentsjoins->params;
 683          }
 684          $select .= "\n$where)";
 685  
 686          $DB->delete_records_select('quiz_overview_regrades', $select, $params);
 687      }
 688  
 689      /**
 690       * Update the final grades for all attempts. This method is used following a regrade.
 691       *
 692       * @param stdClass $quiz the quiz settings.
 693       */
 694      protected function update_overall_grades($quiz) {
 695          $gradecalculator = $this->quizobj->get_grade_calculator();
 696          $gradecalculator->recompute_all_attempt_sumgrades();
 697          $gradecalculator->recompute_all_final_grades();
 698          quiz_update_grades($quiz);
 699      }
 700  
 701      /**
 702       * Get the bands configuration for the quiz.
 703       *
 704       * This returns the configuration for having between 11 and 20 bars in
 705       * a chart based on the maximum grade to be given on a quiz. The width of
 706       * a band is the number of grade points it encapsulates.
 707       *
 708       * @param stdClass $quiz The quiz object.
 709       * @return array Contains the number of bands, and their width.
 710       */
 711      public static function get_bands_count_and_width($quiz) {
 712          $bands = $quiz->grade;
 713          while ($bands > 20 || $bands <= 10) {
 714              if ($bands > 50) {
 715                  $bands /= 5;
 716              } else if ($bands > 20) {
 717                  $bands /= 2;
 718              }
 719              if ($bands < 4) {
 720                  $bands *= 5;
 721              } else if ($bands <= 10) {
 722                  $bands *= 2;
 723              }
 724          }
 725          // See MDL-34589. Using doubles as array keys causes problems in PHP 5.4, hence the explicit cast to int.
 726          $bands = (int) ceil($bands);
 727          return [$bands, $quiz->grade / $bands];
 728      }
 729  
 730      /**
 731       * Get the bands labels.
 732       *
 733       * @param int $bands The number of bands.
 734       * @param int $bandwidth The band width.
 735       * @param stdClass $quiz The quiz object.
 736       * @return string[] The labels.
 737       */
 738      public static function get_bands_labels($bands, $bandwidth, $quiz) {
 739          $bandlabels = [];
 740          for ($i = 1; $i <= $bands; $i++) {
 741              $bandlabels[] = quiz_format_grade($quiz, ($i - 1) * $bandwidth) . ' - ' . quiz_format_grade($quiz, $i * $bandwidth);
 742          }
 743          return $bandlabels;
 744      }
 745  
 746      /**
 747       * Get a chart.
 748       *
 749       * @param string[] $labels Chart labels.
 750       * @param int[] $data The data.
 751       * @return \core\chart_base
 752       */
 753      protected static function get_chart($labels, $data) {
 754          $chart = new \core\chart_bar();
 755          $chart->set_labels($labels);
 756          $chart->get_xaxis(0, true)->set_label(get_string('gradenoun'));
 757  
 758          $yaxis = $chart->get_yaxis(0, true);
 759          $yaxis->set_label(get_string('participants'));
 760          $yaxis->set_stepsize(max(1, round(max($data) / 10)));
 761  
 762          $series = new \core\chart_series(get_string('participants'), $data);
 763          $chart->add_series($series);
 764          return $chart;
 765      }
 766  }