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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body