Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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