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.
   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;
  18  
  19  use coding_exception;
  20  use mod_quiz\event\quiz_grade_updated;
  21  use question_engine_data_mapper;
  22  use stdClass;
  23  
  24  /**
  25   * This class contains all the logic for computing the grade of a quiz.
  26   *
  27   * There are two sorts of calculation which need to be done. For a single
  28   * attempt, we need to compute the total attempt score from score for each question.
  29   * And for a quiz user, we need to compute the final grade from all the separate attempt grades.
  30   *
  31   * @package   mod_quiz
  32   * @copyright 2023 The Open University
  33   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  34   */
  35  class grade_calculator {
  36  
  37      /** @var float a number that is effectively zero. Used to avoid division-by-zero or underflow problems. */
  38      const ALMOST_ZERO = 0.000005;
  39  
  40      /** @var quiz_settings the quiz for which this instance computes grades. */
  41      protected $quizobj;
  42  
  43      /**
  44       * Constructor. Recommended way to get an instance is $quizobj->get_grade_calculator();
  45       *
  46       * @param quiz_settings $quizobj
  47       */
  48      protected function __construct(quiz_settings $quizobj) {
  49          $this->quizobj = $quizobj;
  50      }
  51  
  52      /**
  53       * Factory. The recommended way to get an instance is $quizobj->get_grade_calculator();
  54       *
  55       * @param quiz_settings $quizobj settings of a quiz.
  56       * @return grade_calculator instance of this class for the given quiz.
  57       */
  58      public static function create(quiz_settings $quizobj): grade_calculator {
  59          return new self($quizobj);
  60      }
  61  
  62      /**
  63       * Update the sumgrades field of the quiz.
  64       *
  65       * This needs to be called whenever the grading structure of the quiz is changed.
  66       * For example if a question is added or removed, or a question weight is changed.
  67       *
  68       * You should call {@see quiz_delete_previews()} before you call this function.
  69       */
  70      public function recompute_quiz_sumgrades(): void {
  71          global $DB;
  72          $quiz = $this->quizobj->get_quiz();
  73  
  74          // Update sumgrades in the database.
  75          $DB->execute("
  76                  UPDATE {quiz}
  77                     SET sumgrades = COALESCE((
  78                          SELECT SUM(maxmark)
  79                            FROM {quiz_slots}
  80                           WHERE quizid = {quiz}.id
  81                         ), 0)
  82                   WHERE id = ?
  83               ", [$quiz->id]);
  84  
  85          // Update the value in memory.
  86          $quiz->sumgrades = $DB->get_field('quiz', 'sumgrades', ['id' => $quiz->id]);
  87  
  88          if ($quiz->sumgrades < self::ALMOST_ZERO && quiz_has_attempts($quiz->id)) {
  89              // If the quiz has been attempted, and the sumgrades has been
  90              // set to 0, then we must also set the maximum possible grade to 0, or
  91              // we will get a divide by zero error.
  92              self::update_quiz_maximum_grade(0);
  93          }
  94  
  95          $callbackclasses = \core_component::get_plugin_list_with_class('quiz', 'quiz_structure_modified');
  96          foreach ($callbackclasses as $callbackclass) {
  97              component_class_callback($callbackclass, 'callback', [$quiz->id]);
  98          }
  99      }
 100  
 101      /**
 102       * Update the sumgrades field of attempts at this quiz.
 103       */
 104      public function recompute_all_attempt_sumgrades(): void {
 105          global $DB;
 106          $dm = new question_engine_data_mapper();
 107          $timenow = time();
 108  
 109          $DB->execute("
 110                  UPDATE {quiz_attempts}
 111                     SET timemodified = :timenow,
 112                         sumgrades = (
 113                             {$dm->sum_usage_marks_subquery('uniqueid')}
 114                         )
 115                   WHERE quiz = :quizid AND state = :finishedstate
 116              ", [
 117                  'timenow' => $timenow,
 118                  'quizid' => $this->quizobj->get_quizid(),
 119                  'finishedstate' => quiz_attempt::FINISHED
 120              ]);
 121      }
 122  
 123      /**
 124       * Update the final grade at this quiz for a particular student.
 125       *
 126       * That is, given the quiz settings, and all the attempts this user has made,
 127       * compute their final grade for the quiz, as shown in the gradebook.
 128       *
 129       * The $attempts parameter is for efficiency. If you already have the data for
 130       * all this user's attempts loaded (for example from {@see quiz_get_user_attempts()}
 131       * or because you are looping through a large recordset fetched in one efficient query,
 132       * then you can pass that data here to save DB queries.
 133       *
 134       * @param int|null $userid The userid to calculate the grade for. Defaults to the current user.
 135       * @param array $attempts if you already have this user's attempt records loaded, pass them here to save queries.
 136       */
 137      public function recompute_final_grade(?int $userid = null, array $attempts = []): void {
 138          global $DB, $USER;
 139          $quiz = $this->quizobj->get_quiz();
 140  
 141          if (empty($userid)) {
 142              $userid = $USER->id;
 143          }
 144  
 145          if (!$attempts) {
 146              // Get all the attempts made by the user.
 147              $attempts = quiz_get_user_attempts($quiz->id, $userid);
 148          }
 149  
 150          // Calculate the best grade.
 151          $bestgrade = $this->compute_final_grade_from_attempts($attempts);
 152          $bestgrade = quiz_rescale_grade($bestgrade, $quiz, false);
 153  
 154          // Save the best grade in the database.
 155          if (is_null($bestgrade)) {
 156              $DB->delete_records('quiz_grades', ['quiz' => $quiz->id, 'userid' => $userid]);
 157  
 158          } else if ($grade = $DB->get_record('quiz_grades',
 159                  ['quiz' => $quiz->id, 'userid' => $userid])) {
 160              $grade->grade = $bestgrade;
 161              $grade->timemodified = time();
 162              $DB->update_record('quiz_grades', $grade);
 163  
 164          } else {
 165              $grade = new stdClass();
 166              $grade->quiz = $quiz->id;
 167              $grade->userid = $userid;
 168              $grade->grade = $bestgrade;
 169              $grade->timemodified = time();
 170              $DB->insert_record('quiz_grades', $grade);
 171          }
 172  
 173          quiz_update_grades($quiz, $userid);
 174      }
 175  
 176      /**
 177       * Calculate the overall grade for a quiz given a number of attempts by a particular user.
 178       *
 179       * @param array $attempts an array of all the user's attempts at this quiz in order.
 180       * @return float|null the overall grade, or null if the user does not have a grade.
 181       */
 182      protected function compute_final_grade_from_attempts(array $attempts): ?float {
 183  
 184          $grademethod = $this->quizobj->get_quiz()->grademethod;
 185          switch ($grademethod) {
 186  
 187              case QUIZ_ATTEMPTFIRST:
 188                  $firstattempt = reset($attempts);
 189                  return $firstattempt->sumgrades;
 190  
 191              case QUIZ_ATTEMPTLAST:
 192                  $lastattempt = end($attempts);
 193                  return $lastattempt->sumgrades;
 194  
 195              case QUIZ_GRADEAVERAGE:
 196                  $sum = 0;
 197                  $count = 0;
 198                  foreach ($attempts as $attempt) {
 199                      if (!is_null($attempt->sumgrades)) {
 200                          $sum += $attempt->sumgrades;
 201                          $count++;
 202                      }
 203                  }
 204                  if ($count == 0) {
 205                      return null;
 206                  }
 207                  return $sum / $count;
 208  
 209              case QUIZ_GRADEHIGHEST:
 210                  $max = null;
 211                  foreach ($attempts as $attempt) {
 212                      if ($attempt->sumgrades > $max) {
 213                          $max = $attempt->sumgrades;
 214                      }
 215                  }
 216                  return $max;
 217  
 218              default:
 219                  throw new coding_exception('Unrecognised grading method ' . $grademethod);
 220          }
 221      }
 222  
 223      /**
 224       * Update the final grade at this quiz for all students.
 225       *
 226       * This function is equivalent to calling {@see recompute_final_grade()} for all
 227       * users who have attempted the quiz, but is much more efficient.
 228       */
 229      public function recompute_all_final_grades(): void {
 230          global $DB;
 231          $quiz = $this->quizobj->get_quiz();
 232  
 233          // If the quiz does not contain any graded questions, then there is nothing to do.
 234          if (!$quiz->sumgrades) {
 235              return;
 236          }
 237  
 238          $param = ['iquizid' => $quiz->id, 'istatefinished' => quiz_attempt::FINISHED];
 239          $firstlastattemptjoin = "JOIN (
 240                  SELECT
 241                      iquiza.userid,
 242                      MIN(attempt) AS firstattempt,
 243                      MAX(attempt) AS lastattempt
 244  
 245                  FROM {quiz_attempts} iquiza
 246  
 247                  WHERE
 248                      iquiza.state = :istatefinished AND
 249                      iquiza.preview = 0 AND
 250                      iquiza.quiz = :iquizid
 251  
 252                  GROUP BY iquiza.userid
 253              ) first_last_attempts ON first_last_attempts.userid = quiza.userid";
 254  
 255          switch ($quiz->grademethod) {
 256              case QUIZ_ATTEMPTFIRST:
 257                  // Because of the where clause, there will only be one row, but we
 258                  // must still use an aggregate function.
 259                  $select = 'MAX(quiza.sumgrades)';
 260                  $join = $firstlastattemptjoin;
 261                  $where = 'quiza.attempt = first_last_attempts.firstattempt AND';
 262                  break;
 263  
 264              case QUIZ_ATTEMPTLAST:
 265                  // Because of the where clause, there will only be one row, but we
 266                  // must still use an aggregate function.
 267                  $select = 'MAX(quiza.sumgrades)';
 268                  $join = $firstlastattemptjoin;
 269                  $where = 'quiza.attempt = first_last_attempts.lastattempt AND';
 270                  break;
 271  
 272              case QUIZ_GRADEAVERAGE:
 273                  $select = 'AVG(quiza.sumgrades)';
 274                  $join = '';
 275                  $where = '';
 276                  break;
 277  
 278              default:
 279              case QUIZ_GRADEHIGHEST:
 280                  $select = 'MAX(quiza.sumgrades)';
 281                  $join = '';
 282                  $where = '';
 283                  break;
 284          }
 285  
 286          if ($quiz->sumgrades >= self::ALMOST_ZERO) {
 287              $finalgrade = $select . ' * ' . ($quiz->grade / $quiz->sumgrades);
 288          } else {
 289              $finalgrade = '0';
 290          }
 291          $param['quizid'] = $quiz->id;
 292          $param['quizid2'] = $quiz->id;
 293          $param['quizid3'] = $quiz->id;
 294          $param['quizid4'] = $quiz->id;
 295          $param['statefinished'] = quiz_attempt::FINISHED;
 296          $param['statefinished2'] = quiz_attempt::FINISHED;
 297          $param['almostzero'] = self::ALMOST_ZERO;
 298          $finalgradesubquery = "
 299                  SELECT quiza.userid, $finalgrade AS newgrade
 300                  FROM {quiz_attempts} quiza
 301                  $join
 302                  WHERE
 303                      $where
 304                      quiza.state = :statefinished AND
 305                      quiza.preview = 0 AND
 306                      quiza.quiz = :quizid3
 307                  GROUP BY quiza.userid";
 308  
 309          $changedgrades = $DB->get_records_sql("
 310                  SELECT users.userid, qg.id, qg.grade, newgrades.newgrade
 311  
 312                  FROM (
 313                      SELECT userid
 314                      FROM {quiz_grades} qg
 315                      WHERE quiz = :quizid
 316                  UNION
 317                      SELECT DISTINCT userid
 318                      FROM {quiz_attempts} quiza2
 319                      WHERE
 320                          quiza2.state = :statefinished2 AND
 321                          quiza2.preview = 0 AND
 322                          quiza2.quiz = :quizid2
 323                  ) users
 324  
 325                  LEFT JOIN {quiz_grades} qg ON qg.userid = users.userid AND qg.quiz = :quizid4
 326  
 327                  LEFT JOIN (
 328                      $finalgradesubquery
 329                  ) newgrades ON newgrades.userid = users.userid
 330  
 331                  WHERE
 332                      ABS(newgrades.newgrade - qg.grade) > :almostzero OR
 333                      ((newgrades.newgrade IS NULL OR qg.grade IS NULL) AND NOT
 334                                (newgrades.newgrade IS NULL AND qg.grade IS NULL))",
 335                      // The mess on the previous line is detecting where the value is
 336                      // NULL in one column, and NOT NULL in the other, but SQL does
 337                      // not have an XOR operator, and MS SQL server can't cope with
 338                      // (newgrades.newgrade IS NULL) <> (qg.grade IS NULL).
 339                  $param);
 340  
 341          $timenow = time();
 342          $todelete = [];
 343          foreach ($changedgrades as $changedgrade) {
 344  
 345              if (is_null($changedgrade->newgrade)) {
 346                  $todelete[] = $changedgrade->userid;
 347  
 348              } else if (is_null($changedgrade->grade)) {
 349                  $toinsert = new stdClass();
 350                  $toinsert->quiz = $quiz->id;
 351                  $toinsert->userid = $changedgrade->userid;
 352                  $toinsert->timemodified = $timenow;
 353                  $toinsert->grade = $changedgrade->newgrade;
 354                  $DB->insert_record('quiz_grades', $toinsert);
 355  
 356              } else {
 357                  $toupdate = new stdClass();
 358                  $toupdate->id = $changedgrade->id;
 359                  $toupdate->grade = $changedgrade->newgrade;
 360                  $toupdate->timemodified = $timenow;
 361                  $DB->update_record('quiz_grades', $toupdate);
 362              }
 363          }
 364  
 365          if (!empty($todelete)) {
 366              list($test, $params) = $DB->get_in_or_equal($todelete);
 367              $DB->delete_records_select('quiz_grades', 'quiz = ? AND userid ' . $test,
 368                      array_merge([$quiz->id], $params));
 369          }
 370      }
 371  
 372      /**
 373       * Update the quiz setting for the grade the quiz is out of.
 374       *
 375       * This function will update the data in quiz_grades and quiz_feedback, and
 376       * pass the new grades on to the gradebook.
 377       *
 378       * @param float $newgrade the new maximum grade for the quiz.
 379       */
 380      public function update_quiz_maximum_grade(float $newgrade): void {
 381          global $DB;
 382          $quiz = $this->quizobj->get_quiz();
 383  
 384          // This is potentially expensive, so only do it if necessary.
 385          if (abs($quiz->grade - $newgrade) < self::ALMOST_ZERO) {
 386              // Nothing to do.
 387              return;
 388          }
 389  
 390          // Use a transaction.
 391          $transaction = $DB->start_delegated_transaction();
 392  
 393          // Update the quiz table.
 394          $oldgrade = $quiz->grade;
 395          $quiz->grade = $newgrade;
 396          $timemodified = time();
 397          $DB->update_record('quiz', (object) [
 398              'id' => $quiz->id,
 399              'grade' => $newgrade,
 400              'timemodified' => $timemodified,
 401          ]);
 402  
 403          // Rescale the grade of all quiz attempts.
 404          if ($oldgrade < $newgrade) {
 405              // The new total is bigger, so we need to recompute fully to avoid underflow problems.
 406              $this->recompute_all_final_grades();
 407  
 408          } else {
 409              // New total smaller, so we can rescale the grades efficiently.
 410              $DB->execute("
 411                      UPDATE {quiz_grades}
 412                         SET grade = ? * grade, timemodified = ?
 413                       WHERE quiz = ?
 414              ", [$newgrade / $oldgrade, $timemodified, $quiz->id]);
 415          }
 416  
 417          // Rescale the overall feedback boundaries.
 418          if ($oldgrade > self::ALMOST_ZERO) {
 419              // Update the quiz_feedback table.
 420              $factor = $newgrade / $oldgrade;
 421              $DB->execute("
 422                      UPDATE {quiz_feedback}
 423                      SET mingrade = ? * mingrade, maxgrade = ? * maxgrade
 424                      WHERE quizid = ?
 425              ", [$factor, $factor, $quiz->id]);
 426          }
 427  
 428          // Update grade item and send all grades to gradebook.
 429          quiz_grade_item_update($quiz);
 430          quiz_update_grades($quiz);
 431  
 432          // Log quiz grade updated event.
 433          quiz_grade_updated::create([
 434              'context' => $this->quizobj->get_context(),
 435              'objectid' => $quiz->id,
 436              'other' => [
 437                  'oldgrade' => $oldgrade + 0, // Remove trailing 0s.
 438                  'newgrade' => $newgrade,
 439              ]
 440          ])->trigger();
 441  
 442          $transaction->allow_commit();
 443      }
 444  }